Skip to main content

Payment Gateway Integration

Dokumentasi lengkap integrasi payment gateway di MStore Backend dengan support multi-provider (Xendit & Midtrans).

🎯 Overview

Sistem payment gateway MStore mendukung:
  • βœ… Multi-Provider: Xendit (primary), Midtrans (secondary)
  • βœ… Multi-Channel: QRIS, E-Wallet, Virtual Account, Credit Card
  • βœ… Webhook Integration: Real-time payment notification
  • βœ… Offline-First: Sync payment saat online kembali
  • βœ… Idempotent: Retry-safe operations
  • βœ… Audit Trail: Full payment lifecycle tracking

πŸ—οΈ Architecture


πŸ’³ Supported Payment Methods

Xendit (Primary Provider)

MethodChannelStatusNotes
QRISQR_CODEβœ… ProductionDynamic QR via QR Codes API
GoPayEWALLETβœ… ProductionE-Wallet Payment API
OVOEWALLETβœ… ProductionE-Wallet Payment API
DANAEWALLETβœ… ProductionE-Wallet Payment API
ShopeePayEWALLETβœ… ProductionE-Wallet Payment API
VA BCAVIRTUAL_ACCOUNTβœ… ProductionVirtual Account API
VA BNIVIRTUAL_ACCOUNTβœ… ProductionVirtual Account API
VA BRIVIRTUAL_ACCOUNTβœ… ProductionVirtual Account API
VA MandiriVIRTUAL_ACCOUNTβœ… ProductionVirtual Account API
Credit CardCARDSβœ… ProductionCards API

Midtrans (Secondary Provider)

MethodChannelStatusNotes
QRISQR_CODE🚧 DevelopmentSnap API
GoPayEWALLET🚧 DevelopmentSnap API
Credit CardCARDS🚧 DevelopmentSnap API

πŸ“‘ Payment API Endpoints

1. Create Payment Request

Membuat payment request untuk transaksi.
curl -X POST https://api.mushola-store.com/api/v1/pos/payments \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{
    "order_code": "TRX-MC01-BRN-MST-OSK00001-251013-5UJW-6SWG-7",
    "method": "qris",
    "amount": 33000,
    "currency": "IDR",
    "customer_email": "[email protected]",
    "success_url": "https://pos.mushola-store.com/payment/success",
    "failure_url": "https://pos.mushola-store.com/payment/failed"
  }'
Response (QRIS):
{
  "code": 200,
  "data": {
    "qr_url": "https://api.xendit.co/qr_codes/qr_abc123xyz.png",
    "qr_string": "00020101021126...",
    "reference_id": "qr_abc123xyz",
    "transaction_id": "qr_abc123xyz",
    "external_id": "TRX-MC01-BRN-MST-OSK00001-251013-5UJW-6SWG-7",
    "status": "pending",
    "order_code": "TRX-MC01-BRN-MST-OSK00001-251013-5UJW-6SWG-7",
    "method": "qris",
    "amount": 33000,
    "currency": "IDR",
    "channel_code": "QR_CODE",
    "expires_at": "2025-10-13T10:35:00Z",
    "extra": {
      "provider": "xendit",
      "qr_string": "00020101021126..."
    }
  }
}
Response (E-Wallet):
{
  "code": 200,
  "data": {
    "checkout_url": "https://ewallet.gopay.com/checkout/abc123",
    "reference_id": "ewc_abc123xyz",
    "transaction_id": "ewc_abc123xyz",
    "external_id": "TRX-MC01-BRN-MST-OSK00001-251013-5UJW-6SWG-7",
    "status": "pending",
    "order_code": "TRX-MC01-BRN-MST-OSK00001-251013-5UJW-6SWG-7",
    "method": "gopay",
    "amount": 33000,
    "currency": "IDR",
    "channel_code": "EWALLET",
    "expires_at": "2025-10-13T10:35:00Z"
  }
}

2. Get Payment Status

Mengecek status payment request.
curl -X GET https://api.mushola-store.com/api/v1/pos/payments/qr_abc123xyz \
  -H "Authorization: Bearer YOUR_TOKEN"
Response:
{
  "code": 200,
  "data": {
    "reference_id": "qr_abc123xyz",
    "status": "paid",
    "amount": 33000,
    "currency": "IDR",
    "paid_at": "2025-10-13T10:32:15Z",
    "payment_method": "QRIS",
    "channel_code": "QR_CODE"
  }
}

3. Webhook Handler

Endpoint untuk menerima notifikasi dari payment provider.
curl -X POST https://api.mushola-store.com/api/v1/webhook/xendit \
  -H "Content-Type: application/json" \
  -H "X-Callback-Token: YOUR_WEBHOOK_TOKEN" \
  -d '{
    "id": "qr_abc123xyz",
    "external_id": "TRX-MC01-BRN-MST-OSK00001-251013-5UJW-6SWG-7",
    "status": "PAID",
    "amount": 33000,
    "paid_at": "2025-10-13T10:32:15Z"
  }'
Response:
{
  "code": 200,
  "message": "Webhook processed successfully"
}

4. Simulate Payment (Testing)

Endpoint untuk simulasi pembayaran di environment testing.
curl -X POST https://api.mushola-store.com/api/v1/pos/payments/qr_abc123xyz/simulate \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{
    "amount": 33000
  }'
Response:
{
  "code": 200,
  "data": {
    "status": "SUCCEEDED",
    "message": "Payment simulated successfully"
  }
}

πŸ”§ Implementation Details

Xendit Integration

1. QR Codes API (QRIS)

// xendit_service.go
func (c *XenditClient) CreateQRCode(ctx context.Context, req dto.CreatePaymentRequest) (*dto.CreatePaymentResponse, error) {
    payload := xenditQRReq{
        ReferenceID: req.OrderCode,
        Type:        "DYNAMIC",
        Currency:    "IDR",
        Amount:      req.Amount,
        Metadata:    map[string]any{"external_order_code": req.OrderCode},
    }
    
    url := c.BaseURL + "/qr_codes"
    var qr xenditQRResp
    resp, err := c.Resty.R().
        SetContext(ctx).
        SetBasicAuth(c.SecretKey, "").
        SetHeader("Content-Type", "application/json").
        SetBody(payload).
        SetResult(&qr).
        Post(url)
    
    if err != nil {
        return nil, err
    }
    
    return &dto.CreatePaymentResponse{
        QRURL:       qr.ImageURL,
        ReferenceID: qr.ID,
        Status:      "pending",
        Extra: map[string]any{
            "provider":  "xendit",
            "qr_string": qr.QrString,
        },
    }, nil
}

2. Payment Request API (E-Wallet, VA, Cards)

func (c *XenditClient) CreatePaymentRequest(ctx context.Context, req dto.CreatePaymentRequest) (*dto.CreatePaymentResponse, error) {
    // Map method -> channel_code
    channelCode := ""
    switch req.Method {
    case "qris":
        channelCode = "QRIS"
    case "gopay", "ovo", "dana", "shopeepay":
        channelCode = "EWALLET"
    case "va_bca", "va_bni", "va_bri", "va_mandiri":
        channelCode = "VIRTUAL_ACCOUNT"
    case "credit_card":
        channelCode = "CARDS"
    }
    
    payload := xenditPaymentReq{
        ReferenceID:       req.OrderCode,
        Amount:            req.Amount,
        Currency:          "IDR",
        ChannelCode:       channelCode,
        ChannelProperties: buildChannelProperties(req),
        Metadata:          map[string]any{"order_code": req.OrderCode},
    }
    
    url := c.BaseURL + "/payment_requests"
    var pr xenditPaymentResp
    resp, err := c.Resty.R().
        SetContext(ctx).
        SetBasicAuth(c.SecretKey, "").
        SetHeader("Content-Type": "application/json").
        SetBody(payload).
        SetResult(&pr).
        Post(url)
    
    return mapToPaymentResponse(pr), err
}

3. Webhook Handler

func (s *service) HandleNotification(ctx context.Context, payload map[string]any) error {
    invoiceID := payload["id"].(string)
    externalID := payload["external_id"].(string)
    status := payload["status"].(string)
    
    // Map status Xendit -> internal
    statusMapped := mapXenditStatus(status)
    
    // Update payment record
    merchID, branchID, txID, _ := s.trxService.ResolveByTransactionCode(ctx, externalID)
    _ = s.payRepo.UpsertFromWebhook(ctx, invoiceID, externalID, statusMapped, payload, merchID, branchID, txID)
    
    // Update transaction status via state machine
    if txID != nil && statusMapped == "paid" {
        verifyPayload := transaction.PayloadVerifyPayment{
            PaymentGatewayRef: invoiceID,
        }
        _, err := s.trxService.VerifyPayment(ctx, int64(*txID), verifyPayload)
        if err != nil {
            // Log error tapi jangan fail webhook
            log.Printf("[webhook] failed to verify payment: %v", err)
        }
    }
    
    return nil
}

Payment Model (Database)

// payment_model.go
type Payment struct {
    ID                 uint           `gorm:"primaryKey"`
    MerchantID         uint           `gorm:"not null"`
    BranchID           *uint
    TransactionID      *uint
    PaymentCode        *string
    ReferenceID        *string
    IdempotencyKey     *string
    Provider           string         `gorm:"not null"` // xendit, midtrans
    Channel            *string        // QR_CODE, EWALLET, VIRTUAL_ACCOUNT, CARDS
    Method             *string        // qris, gopay, ovo, dana, va_bca, etc
    Amount             float64        `gorm:"type:decimal(18,2)"`
    Currency           string         `gorm:"not null"`
    Status             string         `gorm:"not null"` // pending, success, failed, expired, canceled
    ExternalPaymentID  *string
    ExternalInvoiceID  *string
    QRString           *string
    PaidAt             *time.Time
    ExpiresAt          *time.Time
    LastErrorCode      *string
    LastErrorMessage   *string
    WebhookStatus      *string
    WebhookReceivedAt  *time.Time
    IsOffline          bool
    DeviceID           *string
    OfflineReference   *string
    SyncStatus         string         // pending, synced, failed
    SyncAttempts       *int
    LastSyncAt         *time.Time
    RequestPayload     datatypes.JSON
    ResponsePayload    datatypes.JSON
    CallbackPayload    datatypes.JSON
    CreatedAt          *time.Time
    UpdatedAt          *time.Time
    DeletedAt          gorm.DeletedAt
}

πŸ”’ Security

1. Webhook Verification

// Verify Xendit webhook signature
func verifyXenditWebhook(payload []byte, signature string, token string) bool {
    mac := hmac.New(sha256.New, []byte(token))
    mac.Write(payload)
    expectedSignature := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(signature), []byte(expectedSignature))
}

2. API Key Management

# Environment variables
XENDIT_SECRET_KEY=xnd_development_...
XENDIT_WEBHOOK_TOKEN=your_webhook_verification_token
MIDTRANS_SERVER_KEY=SB-Mid-server-...
MIDTRANS_CLIENT_KEY=SB-Mid-client-...

3. Idempotency

// Generate idempotency key
idempotencyKey := fmt.Sprintf("%s-%s-%d", 
    merchantID, 
    transactionCode, 
    time.Now().Unix())

πŸ§ͺ Testing

Unit Test Example

func TestCreateQRCode(t *testing.T) {
    client := NewXenditClient("test_key", "https://api.xendit.co")
    
    req := dto.CreatePaymentRequest{
        OrderCode: "TRX-TEST-001",
        Method:    "qris",
        Amount:    50000,
        Currency:  "IDR",
    }
    
    resp, err := client.CreateQRCode(context.Background(), req)
    
    assert.NoError(t, err)
    assert.NotEmpty(t, resp.QRURL)
    assert.Equal(t, "pending", resp.Status)
}

Integration Test with Godog

Feature: Payment Gateway Integration
  
  Scenario: Create QRIS payment
    Given I have a transaction "TRX-TEST-001" with amount 50000
    When I create a QRIS payment request
    Then I should receive a QR code URL
    And payment status should be "pending"
  
  Scenario: Handle payment webhook
    Given I have a pending payment "qr_abc123"
    When Xendit sends a webhook with status "PAID"
    Then payment status should be updated to "success"
    And transaction status should be "paid"

πŸ“Š Monitoring

Payment Metrics

// Metrics to track
type PaymentMetrics struct {
    TotalRequests       int64
    SuccessCount        int64
    FailedCount         int64
    PendingCount        int64
    AverageResponseTime time.Duration
    WebhookReceived     int64
    WebhookFailed       int64
}

Grafana Dashboard

  • Payment Success Rate: success_count / total_requests * 100
  • Payment Latency: P50, P95, P99 response time
  • Webhook Delivery: Success vs Failed
  • Provider Availability: Uptime per provider

πŸ’‘ Best Practices

DO βœ…

  • Simpan full request/response payload untuk audit
  • Gunakan idempotency key untuk retry safety
  • Verify webhook signature sebelum process
  • Handle webhook idempotent (cek duplicate)
  • Set proper timeout untuk external API calls
  • Monitor payment success rate per provider
  • Implement circuit breaker untuk provider failures

DON’T ❌

  • Jangan hardcode API keys di source code
  • Jangan skip webhook verification
  • Jangan block webhook response (process async)
  • Jangan expose payment details di client-side
  • Jangan retry webhook infinitely
  • Jangan ignore payment expiration

πŸ†˜ Troubleshooting

Problem: Payment Stuck in Pending

Symptoms: Status tidak update setelah customer bayar Solution:
  1. Check webhook logs di Xendit dashboard
  2. Verify webhook URL accessible dari internet
  3. Manual check payment status via API
  4. Re-send webhook dari Xendit dashboard

Problem: QR Code Not Generated

Symptoms: Error saat create QR code Solution:
  1. Verify Xendit API key valid
  2. Check amount minimum (Rp 1.500)
  3. Verify currency = β€œIDR”
  4. Check Xendit API status

Problem: Webhook Signature Invalid

Symptoms: Webhook rejected dengan error signature Solution:
  1. Verify webhook token di environment
  2. Check payload format (raw body)
  3. Verify HMAC calculation
  4. Check Xendit webhook settings

Transaction Flow

Alur transaksi lengkap dengan state machine dan offline-first

Xendit API Reference

Official Xendit documentation

Midtrans API Reference

Official Midtrans documentation

Need Help? Contact backend team atau check GitHub Issues