Lewati ke konten utama

PSP Integration API Contract — kesles_merchant ↔ payment.kesles.com

Kontrak API bi-directional antara dua service internal PT Kesles:

  • kesles_merchant — owner data merchant, mobile app + dashboard. Dua backend Go yang relevan: services/dashboard_api (lookup API) + services/payment_service (event receiver).
  • payment.kesles.com — Payment Service Provider Kesles, H2H ke bank. Backend: Go.

Dokumen ini didefinisikan oleh tim kesles_merchant (owner data merchant).

Audience scope (per 2026-04-30)

Dokumen ini awalnya didesain untuk Tier 1 — PSP Internal (payment.kesles.com). Implementasi Go di services/dashboard_api sudah multi-tenant: auth dual-source (psp.api_keys + partner.api_credentials HMAC), middleware attach psp_role + psp_bank_code ke request context, handler /api/psp/v1/merchants/* inject WHERE bank_code = $X otomatis untuk caller dengan role bank.

Tier 2 — PSP External / Bank (Mandiri, BRI, BNI, acquirer lain) sekarang ready untuk onboarding via:

  1. Insert row di partner.partners (psp_role='bank', psp_bank_code='BMRI'/'BRIN'/'BBNI'/dst).
  2. Insert row di partner.api_credentials (auth_method='hmac', hmac_secret_encrypted via psp-encrypt CLI, optional allowed_ip_ranges).

Handler otomatis filter per bank_code — bank tidak akan pernah melihat merchant acquirer lain. Endpoint Tier-1-only (PATCH /api/psp/v1/merchants/{id}/nmid-assignment + semua /psp/v1/events + /psp/v1/settlements) reject 403 (tier_internal_required) untuk caller dengan psp_role != 'internal'.

Body kontrak di bawah masih ditulis dari perspektif Tier 1 (payment.kesles.com). Untuk Tier 2 onboarding penuh + sample request/response, lihat checklist di database/schema/views.md §4.5.


1. Arsitektur & Prinsip

1.1 Service Responsibilities

Aspekkesles_merchantpayment.kesles.com
Owner dataMerchant profil, KYC, outlet, user, analytics, revenue, profitTransaksi QRIS master, settlement, QRIS payload, bank credentials
Publik-facingMobile app merchant + web dashboardH2H ke bank, customer payment page
DB schemamerchant.*, psp.*, db_reference.*payment.* (own DB)
Public APIapi-merchant.kesles.com/api/psp/v1/* + /api/partner/v1/* (PSP & Partner external integrator). Mobile app merchant memakai jalur internal kesles.com/merchant/api/*, BUKAN subdomain publik.(via payment.kesles.com, tidak di scope doc ini)

1.2 Komunikasi Bi-Directional

┌─────────────────────────────────────┐
│ │
│ payment.kesles.com │
│ (Go backend) │
│ │
└────────────┬─────────────▲──────────┘
│ │
outbound │ │ inbound
(lookup │ │ (event
merchant) │ │ forward)
▼ │
┌───────────────┐ ┌──────────────────┐
│ GET /api/psp/ │ │ POST /api/psp/ │
│ v1/merchants │ │ v1/payment- │
│ /{id} │ │ events/{type} │
│ /by-nmid │ │ │
└───────┬───────┘ └──────────┬───────┘
│ │
▼ ▼
┌─────────────────────────────────────┐
│ │
│ kesles_merchant │
│ (dashboard-api, Go) │
│ │
└────────────┬───────────────▲────────┘
│ │
outbound │ │ inbound
(merchant │ │ (NMID
lifecycle) │ │ assignment)
▼ │
┌────────────────┐ ┌────────────────────┐
│ POST │ │ PATCH │
│ payment.kesles.│ │ /api/psp/v1/merchants/│
│ com/api/... │ │ {id}/nmid- │
│ /merchants │ │ assignment │
└────────────────┘ └────────────────────┘

1.3 Prinsip Desain

  1. Idempotent — semua operasi bisa diulang tanpa side effect duplikat (via X-Idempotency-Key)
  2. Active-only exposure — kesles_merchant hanya expose merchant dengan status = 'active' AND deleted_at IS NULL AND nmid IS NOT NULL ke PSP
  3. Audit log semua request — di psp.event_log dan psp.outbound_requests
  4. Retry-safe — kedua service harus handle retry dengan backoff exponential
  5. Eventual consistency — data boleh lag ≤30 detik antar service

2. Authentication & Security

2.1 Method: HMAC-SHA256 + IP Allowlist

Ada dua HMAC scheme tergantung endpoint group. Format string-to-sign dan format timestamp BERBEDA antara kedua scheme — bukan hanya nama header.

Scheme A — Lookup API (/api/psp/v1/*dashboard_api:8082):

X-API-Key-ID: <identifier public — aman di-log>
X-Timestamp: <unix seconds, current>
X-Signature: hmac-sha256=<computed signature>
X-Request-ID: <uuid v4 untuk tracing>

Scheme B — Event Receiver (/psp/v1/events, /psp/v1/settlementspayment_service:8085):

X-PSP-Key-ID: <identifier public — aman di-log>
X-PSP-Timestamp: <RFC3339 timestamp, current — contoh: 2026-05-23T10:02:15Z>
X-PSP-Signature: <hex signature tanpa prefix>
X-Request-ID: <uuid v4 untuk tracing>
X-Idempotency-Key: <uuid v4, per-event, wajib untuk POST /psp/v1/events>

Credential untuk dua scheme ini disimpan di tabel dan database yang berbeda — lihat §2.3 dan §2.7.

2.2 Signature Computation

Format string-to-sign BERBEDA untuk kedua scheme

Scheme A dan Scheme B punya format dan timestamp type yang berbeda. Implementasi yang salah akan selalu menghasilkan 401.

Scheme A — Lookup API (dashboard_api:8082):

String-to-sign: {unix_seconds}\n{METHOD}\n{path+query}\n{body}

1716452580
GET
/api/psp/v1/merchants/abc-123?active=true

  • Timestamp: unix seconds (integer, sesuai X-Timestamp header)
  • Body kosong untuk GET → trailing newline tetap ada
  • Source: packages/psp_integration_go/signing.goBuildStringToSign()

Scheme B — Event Receiver (payment_service:8085):

String-to-sign: {METHOD}\n{path}\n{RFC3339_timestamp}\n{body}

POST
/psp/v1/events
2026-05-23T10:02:15Z
{"external_event_id":"evt-123","event_type":"transaction.success",...}
  • Timestamp: RFC3339 string (sesuai X-PSP-Timestamp header, verbatim)
  • Path tanpa query string (event endpoint tidak punya query params)
  • Source: payment_service/internal/payment/transport/http/psp_auth.go line 139

HMAC-SHA256 dengan shared secret (sama untuk kedua scheme):

mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(stringToSign))
// Scheme A: prefix "hmac-sha256="
signature := "hmac-sha256=" + hex.EncodeToString(mac.Sum(nil))
// Scheme B: hex saja, tanpa prefix
signatureB := hex.EncodeToString(mac.Sum(nil))

2.3 Server Verification (Critical)

Server wajib lakukan 5 check sebelum execute handler:

Scheme A (Lookup — dashboard_api):

  1. Extract X-API-Key-ID → lookup secret dari psp.api_keys (legacy) atau partner.api_credentials di db_kesles_merchant (decrypt via AES-256-GCM)
  2. Check timestamp freshness — reject kalau now - X-Timestamp > 300 detik
  3. Re-compute HMAC, compare dengan X-Signature via hmac.Equal() (constant-time)
  4. Check IP allowlist (optional): partner.api_credentials.allowed_ip_ranges
  5. Rate limit per API Key ID

Scheme B (Event Receiver — payment_service):

  1. Extract X-PSP-Key-ID → lookup secret dari psp.api_keys di db_kesles_merchant_payment (decrypt via AES-256-GCM, key: PSP_HMAC_ENCRYPTION_KEY)
  2. Parse X-PSP-Timestamp sebagai RFC3339 → reject kalau |now - parsedTS| > 300 detik
  3. Re-compute HMAC dari METHOD\nPATH\nTIMESTAMP_STRING\nBODY, compare dengan X-PSP-Signature (plain hex, constant-time)
  4. Check IP allowlist (optional): psp.api_keys.allowed_ip_ranges
  5. Rate limit per Key ID

Kalau salah satu fail: return 401 atau 403 tanpa leak info spesifik (prevent enumeration attack).

2.4 API Key Rotation

  • Rotate setiap 90 hari — di-track via field expires_at di partner.api_credentials. Automated reminder di UI admin BELUM diimplementasi — operator harus track expiry secara manual (calendar reminder ~7 hari sebelum expires_at).
  • Dual-key grace period: 7 hari. Dua key aktif simultan, payment.kesles.com pakai new sebagai default, fallback ke old kalau new reject
  • Revocation: immediate via admin dashboard, semua request dengan key lama akan 401

2.5 Secret Management — AES-256-GCM Encryption (IMPLEMENTED)

Implementasi di services/dashboard_api/internal/app/psp_crypto.go:

  • Secret di-generate 32-byte random, base64 encoded (44 char) — openssl rand -base64 32

  • Secret di-encrypt AES-256-GCM dengan master key dari env KESLES_SECRET_ENCRYPTION_KEY

  • Format storage di psp.api_keys.hmac_secret_encrypted:

    enc:v1:<base64-nonce>:<base64-ciphertext-with-GCM-tag>
    • Prefix enc:v1: = version identifier (future-proof untuk key rotation / algo change)
    • Nonce 12-byte random per-encryption (GCM requires unique nonce)
    • Ciphertext + auth tag combined (GCM standard)
  • Dev mode fallback: String tanpa prefix enc:v1: → treated as plaintext + WARN log. Production HARUS semua encrypted — tambahkan startup check untuk alert plaintext rows.

  • Plain secret tidak pernah di-log, di-return dalam API response, atau tersimpan plaintext di DB production.

Helper CLI untuk encrypt secret baru:

KESLES_SECRET_ENCRYPTION_KEY=<base64-master-key> \
go run ./cmd/psp-encrypt <plaintext-secret>

# Output format (placeholder — actual value berbeda setiap run karena nonce random):
# enc:v1:<base64-nonce-12bytes>:<base64-ciphertext-with-gcm-tag-truncated>...

Output ini yang di-INSERT ke kolom hmac_secret_encrypted.

2.6 Master Key Rotation

Saat master key (KESLES_SECRET_ENCRYPTION_KEY) perlu di-rotate:

  1. Generate new master key: openssl rand -base64 32
  2. Deploy app dengan dual-key support — try new key first, fallback ke old (tidak covered di implementation saat ini — future enhancement)
  3. Re-encrypt semua rows di psp.api_keys dengan key baru
  4. After 7 days grace period, remove old key dari env

Untuk MVP: rotation tetap manual (re-run psp-encrypt CLI + UPDATE SQL).

2.7 Credential Handover untuk payment.kesles.com Team

Flow handover ke tim external:

  1. Admin generate API key di Admin UI (atau manual SQL insert untuk MVP)
  2. System output plaintext secret SATU KALI — tidak bisa di-recover
  3. Admin share secret via secure channel:
    • 1Password Teams shared vault
    • Encrypted Slack DM
    • Bitwarden Organization
    • NEVER via email, WhatsApp biasa, atau document tidak ter-encrypt
  4. Tim payment.kesles.com store di env var mereka (KESLES_MERCHANT_HMAC_SECRET)
  5. Rotation notification dikirim 7 hari sebelum expire (expires_at di psp.api_keys)

2.8 Audit Log Requirements

Semua request inbound wajib di-log:

  • Lookup API (/api/psp/v1/*): di psp.outbound_requests (dashboard_api, db_kesles_merchant)
  • Event Receiver (/psp/v1/events, /psp/v1/settlements): di psp.event_log (payment_service, db_kesles_merchant_payment)

Field yang di-log:

  • Timestamp
  • Caller IP
  • API Key ID (bukan secret)
  • Endpoint + method
  • Request ID
  • Response status
  • Duration ms
  • Error (kalau ada)

Retention: 5 tahun (BI compliance untuk payment data).


3. Endpoint Reference — PSP Lookup API

Prefix: /api/psp/v1/* Auth: HMAC (lihat §2) Consumer: payment.kesles.com

3.1 GET /api/psp/v1/merchants

List semua merchant active dengan NMID assigned. Cursor paginated.

Query params:

ParamTypeDefaultDescription
limitint100Max 500
cursorstring-Opaque pagination cursor
updated_sincedatetime (ISO8601)-Delta sync — hanya return merchant yang updated_at ≥ value
bank_codestring-Filter per PSP bank (BMRI, BCA, dll)
category_codestring-Filter MCC category
include_outletsboolfalseInclude outlets di setiap merchant (expensive)

Response 200:

{
"items": [
{
"id": "b8f3a2c1-uuid",
"merchant_name": "Warung Pak Budi",
"legal_name": "CV Sumber Rejeki",
"nmid": "ID102326873XXXX",
"bank_code": "BMRI",
"category_code": "5411",
"category_name": "Grocery Stores",
"phone": "081234567890",
"email": "budi@example.com",
"npwp": "01.234.567.8-123.000",
"address": {
"line": "Jl. Sudirman No. 123",
"city": "Makassar",
"province": "Sulawesi Selatan",
"postal_code": "90111",
"country": "ID"
},
"status": "active",
"kyc_status": "verified",
"kyc_verified_at": "2026-05-01T09:30:00Z",
"created_at": "2026-05-01T08:00:00Z",
"updated_at": "2026-05-23T14:23:45Z",
"psp_registration_id": "psp-reg-uuid-123"
}
],
"pagination": {
"next_cursor": "eyJpZCI6ImJmYTMifQ==",
"has_more": true
},
"meta": {
"returned_count": 100,
"active_only": true
}
}

Errors:

  • 400 invalid_cursor — cursor tidak valid
  • 400 invalid_bank_code — bank_code tidak ada di ref
  • 401 unauthorized — auth gagal
  • 429 rate_limit_exceeded

Rate limit: 60 req/menit per API key (bulk endpoint, jangan di-call terus-terusan)

Performance: p95 <300ms untuk limit=100

3.2 GET /api/psp/v1/merchants/{id}

Get single merchant by internal merchant_id (UUID).

Path params:

  • id — UUID merchant

Query params:

  • include_outlets (bool, default false) — include outlets
  • include_owners (bool, default false) — include owner/manager users
  • include_devices (bool, default false) — include daftar device per-merchant (array Device, shape sama dengan §3.6.5). Diimplementasi 2026-06-09 — wired ke §3.2/§3.3/§3.4/§3.5; endpoint dedicated GET /merchants/{id}/devices (§3.6.5) juga tetap live.

Response 200:

{
"merchant": { /* same shape as list item */ },
"outlets": [ /* kalau include_outlets=true */ ],
"owners": [ /* kalau include_owners=true */ ],
"devices": [ /* kalau include_devices=true — Device objects, shape sama §3.6.5 */ ]
}

Errors:

  • 404 merchant_not_found — id tidak ada, bukan active, atau belum punya NMID
  • 401, 429 (same as above)

Rate limit: 600 req/menit per API key (hot endpoint, PSP call tiap transaksi)

Performance: p95 <100ms (cached 30 detik di Redis)

3.3 GET /api/psp/v1/merchants/by-nmid/{nmid}

Reverse lookup: cari merchant by NMID. Paling sering di-call PSP (saat bank webhook masuk dengan NMID).

Path params:

  • nmid — NMID string (e.g., "ID102326873XXXX")

Response 200: Same as 3.2

Errors:

  • 404 merchant_not_found

Rate limit: 1000 req/menit

Performance: p95 <50ms (indexed + cached)

3.4 GET /api/psp/v1/merchants/search

Search merchant untuk duplicate detection atau dispute resolution.

Query params (minimum 1 wajib):

  • phone — exact match
  • email — exact match (case-insensitive)
  • npwp — exact match
  • nmid — exact match

Response 200:

{
"items": [ /* max 10 merchant match */ ],
"match_count": 2
}

Errors:

  • 400 at_least_one_param_required

3.5 GET /api/psp/v1/merchants/{id}/outlets

List outlets milik merchant.

Response 200:

{
"items": [
{
"id": "outlet-uuid",
"merchant_id": "merchant-uuid",
"outlet_name": "Cabang Makassar Pusat",
"nmid_specific": "ID102326873XXXX-01",
"address": { /* ... */ },
"is_primary": true,
"device_count": 2,
"created_at": "...",
"updated_at": "..."
}
]
}

3.6 GET /api/psp/v1/merchants/{id}/owners

List owner/manager user untuk notifikasi kritis (refund approval, dispute).

Response 200:

{
"items": [
{
"user_id": "user-uuid",
"full_name": "Budi Santoso",
"phone_masked": "08**-****-5678",
"email_masked": "b***@example.com",
"role": "owner",
"notification_channels": ["whatsapp", "email"]
}
]
}

3.6.5 GET /api/psp/v1/merchants/{id}/devices

Status

IMPLEMENTED sejak 2026-05-10. Backed by view psp.v_active_merchant_devices (migration 126) — satu baris per device aktif.

Return list lengkap perangkat pembayaran yang terpasang di merchant.

Kenapa endpoint terpisah, bukan tambah field di merchant snapshot:

  • Satu merchant bisa punya beberapa terminal (chain restaurant, retail multi-counter). Snapshot merchant di §3.1/§3.2 cuma LATERAL LIMIT 1 — PSP butuh semuanya untuk reconcile transaksi yang routed lewat TID tertentu.
  • Field per-device (last_seen_at, placement_name) cuma dibutuhkan saat troubleshooting, bukan setiap lookup.

Response 200 (binary device_status):

{
"items": [
{
"id": "device-uuid",
"merchant_id": "merchant-uuid",
"terminal_code": "TRM-0001",
"device_id": "DEV-9876",
"serial_number": "00087000668",
"device_model": "QRIS Plus Q161 Pro",
"device_status": "active",
"device_status_detail": "online",
"activated_at": "2026-05-07T16:50:11+07:00",
"last_seen_at": "2026-05-08T09:12:33+07:00",
"placement_name": "Kasir Lantai 1"
}
],
"summary": {
"total": 3,
"active": 2,
"inactive": 1
}
}

device_status adalah single binary field (decision: 2026-05-08): active | inactive.

device_statusInternal payment_terminal_inventory.status (mig 024 + 035)
activeactive, online
inactivestock, allocated, in_transit, delivered, inventory, assigned, offline, maintenance, suspended, returned, deleted

PSP tidak butuh granular enum untuk routing decision. Enum lengkap dipublish lewat device_status_detail untuk debug, tapi PSP harus treat sebagai opaque info — binary device_status adalah kontrak yang sebenarnya.

device_model composition: View column = concat_ws(' ', terminal_type_name, product_model_name) (NULL-safe). Untuk seeded default row (mig 024) hasilnya "QRIS Plus Q161 Pro". Frontend / PSP harus treat ini sebagai opaque display text, jangan di-parse.

NMID / MID / TID: Tidak di-expose di payload per-device — mereka tinggal di merchant.merchants (1 set per merchant) sesuai schema sekarang (mig 068, 096). Semua device milik merchant yang sama share identifier yang sama. Untuk dapat NMID/MID/TID, PSP lookup parent merchant lewat /merchants/{id} (§3.2) atau kirim di payload.MID/payload.NMID saat transaction event.

Catatan evolusi schema

Schema multi-outlet di proposal (docs/database/plans/multi-outlet-enterprise-schema-proposal.md) akan pindah NMID/MID ke level outlet, TID ke level device. Saat itu landed, response ini akan tambah tid per-device (kemungkinan juga outlet_id). PSP harus design client yang tolerant terhadap additive fields.

Field opsional yang di-keep (operational debug, devices endpoint only):

  • last_seen_at, activated_at — kapan device terakhir online / pertama aktif.
  • serial_number — PII-gated (Tier 1 internal + Tier 2 bank only). Caller Tier 3 dapat field ini kosong (omit).
  • placement_name — label kasir/outlet untuk konteks.

Field yang sengaja TIDAK di-expose (kept internal di dashboard, di luar kontrak PSP):

  • firmware_version, connectivity_type, manufacturer_name, brand_name, terminal_type_code, payment_capabilities[], placement_address_line, assigned_at, metadata. Ini operasional data; PSP cuma lihat yang dibutuhkan untuk reconcile transaksi dan report ke merchant.

Tier behaviour:

TierCaller (psp_role)Row scopingField serial_number
1internal (payment.kesles.com)tidak ada (semua merchant)visible
2bankWHERE bank_code = caller_bank_codevisible
3none (Tier-3 partner / unauth)per-contextempty (omitempty)

PII gating implementasinya lewat helper pspCallerSeesPII(ctx) di services/dashboard_api/internal/app/psp_merchants.go.

Rate limit: 300 req/menit (cold endpoint; PSP cuma panggil saat reconciling specific terminal).

Errors:

  • 404 merchant_not_found — merchant UUID tidak ada / non-active / di luar bank scope (Tier-2)
  • 401 unauthorized — HMAC fail
  • 405 method_not_allowed — bukan GET
  • 500 internal_error — DB error

3.6.6 GET /api/psp/v1/merchants/by-serial/{serial_number}

Status

IMPLEMENTED sejak 2026-05-10. Reverse lookup dari device serial number ke merchant pemiliknya.

Resolve serial number hardware ke merchant yang punya device tsb. Berguna saat PSP terima error / settlement row yang cuma keyed by SN dan butuh konteks merchant Kesles untuk action.

Path params:

  • serial_number — hardware ID asli dari manufacturer. Format real Aisino Q161 = 11 digit pure numeric (mis. 00087000668). Backend accept lebih luas: regex [A-Za-z0-9_\-]+, no length constraint — supaya operator bisa input dengan prefix internal kalau perlu (mis. SN-AISINO-00001 atau AISINO-DEV-00421 di-pakai sebagian dashboard UI hint, tapi default seharusnya raw SN dari manufacturer). Bukan compound dengan device model SKU — device_model (mis. "QRIS Plus Q161 Pro") adalah field terpisah.

Response 200:

{
"merchant": {
"id": "d0db70bd-bb29-46b3-8653-d59eed6f1fa2",
"merchant_code": "MRC-0174105647947242",
"merchant_name": "Makan Kini",
"nmid": "ID2313123123123",
"mid": "231312312312323",
"tid": "12321312",
"bank_code": "BMRI",
"...": "(full Merchant DTO, sama shape dengan §3.2)"
},
"device": {
"id": "device-uuid",
"merchant_id": "d0db70bd-bb29-46b3-8653-d59eed6f1fa2",
"terminal_code": "TRM-19E083F8195-80240268",
"device_id": "DEV-...",
"serial_number": "00087000668",
"device_model": "QRIS Plus Q161 Pro",
"device_status": "inactive",
"device_status_detail": "delivered",
"activated_at": null,
"last_seen_at": null,
"placement_name": "Kasir Lantai 1"
}
}

Implementasi: dua index-only lookup berturutan ke psp.v_active_merchant_devices (mig 126):

  1. View by SN — pakai unique partial index uk_payment_terminal_inventory_serial_number (mig 024 + 035).
  2. merchants by ID — primary key lookup.

Tier behaviour: sama dengan §3.6.5.

  • Tier-2 bank scoping diterapkan di kedua query — bank A tidak bisa fishing serial milik merchant bank B. Baik view query maupun merchant lookup append WHERE bank_code = caller_bank_code.
  • Caller Tier-3 dapat device.serial_number kosong.

Rate limit: 600 req/menit (lookup endpoint, weight mirip by-nmid / by-mid / by-code).

Errors:

  • 404 device_not_found — SN tidak ada / merchant non-active / di luar bank scope
  • 404 merchant_not_found — race condition (device ada tapi merchant baru saja deleted/deactivated antara dua query)
  • 401 unauthorized — HMAC fail
  • 405 method_not_allowed — bukan GET
  • 500 internal_error

Use case:

  • PSP terima file batch settlement berisi SN gagal → lookup pemilik untuk hubungi
  • Customer service PSP terima telepon "device SN xyz error" → butuh info merchant cepat
  • Forensic / fraud check — "SN ini transaksi besar tiba-tiba, ini merchant mana?"

Tier-2 row scoping: ketika caller psp_role='bank', view query difilter bank_code = caller_bank_code supaya bank tidak bisa reverse-lookup serial milik merchant di acquirer lain.

3.6.7 GET /api/psp/v1/merchants/by-mid/{mid}

Status

IMPLEMENTED. Lookup merchant berdasarkan MID (Merchant ID yang diterbitkan acquirer, biasanya ~15 digit).

Path params:

  • mid — merchant ID dari acquirer. Format: string numerik, umumnya 15 digit (mis. 000007100010926).

Response 200: sama dengan §3.2 (MerchantDetailResponse). Tidak support include_outlets / include_owners.

Tier behaviour: bank-scoped — Tier-2 caller hanya bisa lookup merchant di bawah bank_code mereka.

Rate limit: 600 req/menit.

Errors:

  • 404 merchant_not_found — MID tidak ada atau merchant non-active
  • 401 unauthorized — HMAC fail
  • 405 method_not_allowed — bukan GET

3.6.8 GET /api/psp/v1/merchants/by-code/{merchant_code}

Status

IMPLEMENTED. Lookup merchant berdasarkan merchant_code Kesles. Ini adalah referensi kanonik PSP ke merchant Kesles setelah registrasi.

Path params:

  • merchant_code — kode merchant Kesles, format MRC-XXXXXXXXXXXXXXXX (suffix 16-char hex).

Query params (opsional):

  • include_outlets=true — embed daftar outlet (sama dengan §3.5)
  • include_owners=true — embed daftar pemilik (sama dengan §3.6)

Response 200: sama dengan §3.2 (MerchantDetailResponse).

Tier behaviour: bank-scoped — sama dengan §3.3/§3.6.7.

Rate limit: 600 req/menit.

Errors:

  • 404 merchant_not_found
  • 401 unauthorized
  • 405 method_not_allowed

3.7 GET /api/psp/v1/reference/payment-service-providers

Get list PSP dari db_reference.ref_payment_service_provider.

Query params:

  • active_only (bool, default true)

Response 200:

{
"items": [
{
"psp_id": "uuid",
"psp_code": "BMRI",
"name": "Bank Mandiri",
"api_base_url": "https://api.bankmandiri.co.id/qris/v1",
"is_active": true,
"settlement_bank": "Mandiri",
"supported_qris_types": ["static", "dynamic"]
}
]
}

3.8 GET /api/psp/v1/reference/merchant-categories

MCC codes lookup (untuk standardize category).

3.9 PATCH /api/psp/v1/merchants/{id}/nmid-assignment

PSP callback ke kesles_merchant setelah bank assign NMID ke merchant.

Request body:

{
"bank_code": "BMRI",
"nmid": "ID102326873XXXX",
"qris_static_payload": "00020101021226580013ID...",
"qris_static_url_image": "https://cdn.kesles.id/qr/merchant-uuid.png",
"psp_registration_id": "psp-reg-uuid",
"assigned_at": "2026-05-23T10:00:00Z"
}

Response 200: Updated merchant entity

Errors:

  • 404 merchant_not_found
  • 409 nmid_already_assigned — merchant ini sudah punya NMID (idempotent — kalau request sama, return 200)
  • 422 invalid_nmid_format

4. Endpoint Reference — Event Receiver API

Service: payment_service:8085 Prefix: /psp/v1/* Auth: Scheme BX-PSP-Key-ID / X-PSP-Timestamp / X-PSP-Signature (lihat §2.1) Consumer: payment.kesles.com Credential table: psp.api_keys di db_kesles_merchant_payment

Perubahan dari versi sebelumnya (per 2026-06-05)

Endpoint lama /api/psp/v1/payment-events/* di dashboard_api sudah dihapus (Phase 4 selesai). Seluruh event traffic sekarang masuk ke dua endpoint di bawah di payment_service:8085. Semua event_type (transaction, refund, nmid status change) diterima oleh satu endpoint /psp/v1/events, dibedakan via field event_type di body.

4.1 POST /psp/v1/events

Endpoint tunggal untuk semua event inbound dari PSP. Server routing berdasarkan event_type:

event_typeRouting internal
transaction.success, qris.payment.successFlow A (merchant normal) atau Flow B (NMID korporat) — transaksi berhasil
transaction.failed, qris.payment.failedTransaksi gagal — log ke psp.event_log, push notif ke merchant
transaction.pendingTransaksi dalam proses / menunggu konfirmasi bank
transaction.cancelledTransaksi dibatalkan — update status transaksi
transaction.reversedTransaksi di-reverse oleh bank
*.refunded, transaction.refunded, qris.payment.refundedRefund completion flow
event mengandung nmid_statusNMID status change flow

Headers extra:

  • X-Idempotency-Key — required, unique per event (biasanya pakai external_event_id)

Request body — contoh per event_type:

event_type: transaction.success

{
"external_event_id": "psp-evt-8b3a2c1f",
"event_type": "transaction.success",
"transaction_code": "psp-txn-uuid",
"nmid": "ID102326873XXXX",
"gross_amount": 75000,
"mdr_fee_bank": 300,
"switching_fee": 150,
"platform_fee": 75,
"reference_number": "BI20260523142345XXXX",
"rrn": "301234567890",
"transaction_at": "2026-05-23T14:23:45Z",
"payer_bank_code": "014",
"payer_bank_name": "BCA",
"status": "success"
}

event_type: transaction.cancelled

{
"external_event_id": "psp-evt-cancel-789",
"event_type": "transaction.cancelled",
"nmid": "ID102326873XXXX",
"gross_amount": 75000,
"transaction_code": "TRX-PSP-20260523-001",
"external_reference": "BI20260523142345XXXX",
"transaction_at": "2026-05-23T14:30:00Z",
"reason": "customer_request",
"status": "cancelled"
}

event_type: transaction.reversed

{
"external_event_id": "psp-evt-reverse-321",
"event_type": "transaction.reversed",
"nmid": "ID102326873XXXX",
"gross_amount": 75000,
"transaction_code": "TRX-PSP-20260523-001",
"external_reference": "BI20260523142345XXXX",
"transaction_at": "2026-05-23T15:00:00Z",
"reason": "bank_initiated",
"status": "reversed"
}

Response 200 (first time):

{
"ack": true,
"flow": "A",
"external_event_id": "psp-evt-8b3a2c1f"
}

Response 200 (duplicate, idempotent):

{
"status": "duplicate_skipped"
}

Server behavior:

  1. Verify HMAC (Scheme B: X-PSP-Key-ID / X-PSP-Signature)
  2. Check external_event_id terhadap psp.event_log.external_event_id — duplicate → return 200 {"status":"duplicate_skipped"} tanpa re-process
  3. Route berdasarkan event_type (transaction / refund / nmid_status)
  4. Transaction flow: INSERT payment.transactions, async FCM push ke merchant
  5. Refund flow: update local refund state → completed, mark transaction refunded
  6. NMID status flow: UPDATE merchant status + push notif ke owner
  7. Log ke psp.event_log (db_kesles_merchant_payment)
  8. Return 200

Errors:

  • 401 missing_headers / invalid_signature / timestamp_expired
  • 422 invalid_payloadexternal_event_id atau event_type kosong
  • 500 kalau PSP_HMAC_ENCRYPTION_KEY tidak di-set di env

4.2 POST /psp/v1/settlements

Forward settlement batch harian dari bank.

Request body:

{
"external_event_id": "psp-evt-sett-xxx",
"event_type": "settlement.completed",
"settlement_id": "psp-sett-uuid",
"merchant_id": "kesles-merchant-uuid",
"batch_number": "SETT-20260524-001",
"bank_code": "BMRI",
"transaction_count": 48,
"gross_amount": 3240000,
"mdr_total": 22680,
"net_amount": 3217320,
"bank_account_masked": "1234-****-5678",
"expected_date": "2026-05-24",
"completed_at": "2026-05-24T08:00:00Z"
}

Response 200:

{
"ack": true,
"external_event_id": "psp-evt-sett-xxx"
}

Server behavior: INSERT payment.settlements, idempotent via external_event_id.


5. Outbound Client — kesles_merchant → payment.kesles.com

Endpoint yang kesles_merchant call ke payment.kesles.com. Spec-nya didefinisikan bersama tim payment.kesles.com, tapi contract draft dari sisi kesles_merchant.

5.0 Status Implementasi (Phase 1–3 selesai, dispatch OFF default)

Sudah dibangun (2026-05-20):

  • services/dashboard_api/internal/app/psp_outbound_client.go — singleton client PSPOutboundClient dengan 7 method typed: RegisterMerchant, UpdateMerchant, SuspendMerchant, ReactivateMerchant, TerminateMerchant, InitiateRefund, LookupTransaction. HMAC signing via shared packages/psp_integration_go.Signer (string-to-sign sama dengan verifier inbound).
  • services/dashboard_api/internal/app/psp_outbound_audit.go — wrapper di atas tabel psp.outbound_requests (InsertOutboundRequest + MarkOutboundResult). Tabel sudah ada sejak mig 062 tapi baru sekarang ditulisi.
  • services/dashboard_api/internal/app/psp_outbound_triggers.go — lifecycle dispatcher yang decide method outbound mana yang fire saat transisi state merchant. Sudah di-wire ke:
    • flow approve (dashboard_merchant_registration.go handleMerchantRegistrationApprove) → RegisterMerchant
    • PATCH merchant (dashboard_merchants.go updateMerchantHandler) → Suspend/Reactivate/Terminate/Update tergantung transisi status
    • DELETE merchant (dashboard_merchants.go deleteMerchantHandler) → TerminateMerchant
  • services/dashboard_api/internal/app/dashboard_psp_outbound_admin.go — 4 endpoint observability super-admin (lihat §5.8 di bawah).

Mode switch (env PSP_OUTBOUND_MODE, default disabled):

ModeSide effect
disabled (default)No-op: handler short-circuit di IsEnabled() check. Tidak alokasi, tidak write DB, tidak HTTP.
audit_onlySign + insert row di psp.outbound_requests dengan error_message="dispatch_disabled (audit_only mode)". Tidak HTTP ke payment.kesles.com. Berguna untuk simulasi pre-prod.
dispatchProduksi penuh: sign + audit row + HTTP POST/PATCH/GET ke payment.kesles.com + tulis response ke audit row.

Aktivasi plug-and-play (saat tim payment.kesles.com sudah siap):

# Di services/dashboard_api/.env.<env>
PSP_OUTBOUND_MODE=dispatch
PSP_OUTBOUND_BASE_URL=https://payment.kesles.com
PSP_OUTBOUND_HMAC_KEY_ID=<key id dari tim payment.kesles.com>
PSP_OUTBOUND_HMAC_SECRET=<secret pasangannya>
PSP_OUTBOUND_TIMEOUT=15s

Restart dashboard_apitidak perlu deploy code baru. Trigger point yang selama ini silent no-op akan langsung fire real outbound call. Verifikasi via admin panel Integrations → PSP Outbound (§5.8).

Trigger map (saat fire di produksi):

Event sumberFire methodPath (di payment.kesles.com)
Approve registrasi merchantRegisterMerchantPOST /api/internal/merchants
PATCH status activesuspendedSuspendMerchantPOST /api/internal/merchants/{id}/suspend
PATCH status suspendedactiveReactivateMerchantPOST /api/internal/merchants/{id}/reactivate
PATCH status *inactiveTerminateMerchantPOST /api/internal/merchants/{id}/terminate
PATCH detail (non-status, mis. NPWP/alamat/kontak)UpdateMerchantPATCH /api/internal/merchants/{id}
DELETE merchant (soft-delete)TerminateMerchantPOST /api/internal/merchants/{id}/terminate
Operator klik Refund (handler TBD)InitiateRefundPOST /api/internal/refunds

Semua trigger fire async via goroutine + context.Background() (30 detik timeout) supaya response user tidak ke-block, dan goroutine tetap jalan walaupun HTTP connection user ditutup. Failure = best-effort: hanya di-log + error_message di audit row, tidak rollback DB (state-of-truth ada di kesles_merchant; payment.kesles.com bisa direkonsiliasi via Replay admin tool — lihat §5.8).

5.1 POST payment.kesles.com/api/psp/v1/merchants

Register merchant baru ke PSP (setelah KYC verified di kesles_merchant).

Request body:

{
"kesles_merchant_id": "merchant-uuid",
"merchant_name": "Warung Pak Budi",
"legal_name": "CV Sumber Rejeki",
"category_code": "5411",
"phone": "081234567890",
"email": "budi@example.com",
"npwp": "01.234.567.8-123.000",
"address": { /* ... */ },
"requested_bank_code": "BMRI",
"kyc_documents": {
"ktp_url": "...",
"nib_url": "...",
"selfie_url": "..."
}
}

Response 201 (sync): registered + NMID assigned Response 202 (async): accepted, callback via /api/psp/v1/merchants/{id}/nmid-assignment saat done

5.2 PATCH payment.kesles.com/api/psp/v1/merchants/{id}

Update merchant profile di PSP (saat merchant edit di mobile app).

5.3 POST payment.kesles.com/api/psp/v1/merchants/{id}/suspend

Suspend merchant di bank (dari admin action di kesles_merchant).

5.4 POST payment.kesles.com/api/psp/v1/merchants/{id}/reactivate

Reactivate.

5.5 POST payment.kesles.com/api/psp/v1/merchants/{id}/terminate

Terminate NMID permanently (deleted merchant).

5.6 POST payment.kesles.com/api/psp/refunds

Request refund untuk transaksi.

5.7 GET payment.kesles.com/api/psp/transactions/{reference_number}

Query transaction status (reconciliation).

5.8 Admin Panel — Integrations → PSP Outbound

Super-admin only (role superadmin). Backed by dashboard_psp_outbound_admin.go.

Routes (mounted di server.go di bawah /api/dashboard/integrations/psp-outbound/):

MethodPathFungsi
GET/statusReturn { mode, is_enabled, last_24h: {pending, success, failed} } untuk health card.
GET/audit?merchant_id=&request_type=&status=success|failed|pending&limit=&offset=&page=List paginated psp.outbound_requests. Default limit=50 (max 200).
GET/audit/{id}Drill-down single row (request_payload + response_body sebagai raw JSON).
POST/audit/{id}/replayRe-fire request original. Tulis row baru dengan retry_count = original.retry_count + 1 dan triggered_by_user_id = operator. 503 saat mode=disabled.

Semantik replay:

  • History audit multi-row — setiap attempt insert row baru; original tidak pernah ditimpa. Filter retry_count > 0 di /audit query untuk lihat replay.
  • Replay mode-aware: di audit_only tulis row baru dengan note dispatch_disabled tapi tetap tidak HTTP; di dispatch re-sign + re-send.
  • Idempotensi antar attempt = tanggung jawab payment.kesles.com (de-dupe by X-Request-ID dalam 5-menit replay window).

Status grouping (dipakai filter status=):

  • successresponse_status BETWEEN 200 AND 299
  • failedresponse_status non-2xx atau NULL dengan error_message IS NOT NULL
  • pendingresponse_status IS NULL AND error_message IS NULL (in-flight atau belum pernah dispatch)

6. Error Response Format

Semua error dari kesles_merchant (lookup /api/psp/v1/* + event receiver /psp/v1/*) pakai format konsisten:

{
"error": "error_code_snake_case",
"message": "Human-readable message",
"details": { "optional": "field" },
"request_id": "echo X-Request-ID dari request"
}

Error Code Reference

HTTPError CodeMeaning
400invalid_requestMalformed JSON atau query param
400invalid_cursorCursor tidak valid
400invalid_nmid_formatNMID tidak sesuai format BI
401unauthorizedMissing auth headers
401invalid_signatureHMAC verification failed
401timestamp_expiredTimestamp > 5 menit old
401api_key_revokedAPI Key sudah di-revoke
403ip_not_allowedIP caller tidak di allowlist
403endpoint_not_allowedAPI Key tidak boleh akses endpoint ini
404merchant_not_foundMerchant tidak ada atau non-active
404outlet_not_found
409duplicate_resourceUnique constraint violation
409nmid_already_assignedMerchant sudah punya NMID lain
422validation_failedInput validation fail
429rate_limit_exceededExceeded per-API-key rate limit
500internal_errorUnexpected server error (detail di-log, tidak di-return)

7. Rate Limiting & Performance SLA

7.1 Per API Key Rate Limits

Endpoint PatternRate Limit
GET /api/psp/v1/merchants (bulk list)60 req/menit
GET /api/psp/v1/merchants/{id}600 req/menit
GET /api/psp/v1/merchants/by-nmid/{nmid}1000 req/menit
GET /api/psp/v1/merchants/by-mid/{mid}600 req/menit
GET /api/psp/v1/merchants/by-code/{merchant_code}600 req/menit
GET /api/psp/v1/merchants/by-serial/{serial_number}600 req/menit
GET /api/psp/v1/merchants/search300 req/menit
GET /api/psp/v1/merchants/{id}/devices300 req/menit
GET /api/psp/v1/merchants/{id}/outlets300 req/menit
GET /api/psp/v1/merchants/{id}/owners300 req/menit
GET /api/psp/v1/reference/*100 req/menit
PATCH /api/psp/v1/merchants/{id}/nmid-assignment60 req/menit
POST /psp/v1/events6000 req/menit (100/detik)
POST /psp/v1/settlements600 req/menit

Response headers:

X-RateLimit-Limit: 600
X-RateLimit-Remaining: 543
X-RateLimit-Reset: 1716452640

7.2 Performance SLA

Endpointp50p95p99
GET /merchants/by-nmid/{nmid}30ms50ms100ms
GET /merchants/by-mid/{mid}30ms50ms100ms
GET /merchants/by-code/{merchant_code}30ms50ms100ms
GET /merchants/by-serial/{serial_number}30ms50ms100ms
GET /merchants/{id}50ms100ms200ms
GET /merchants (list limit=100)150ms300ms500ms
GET /merchants/{id}/outlets50ms100ms150ms
PATCH /merchants/{id}/nmid-assignment80ms150ms300ms
POST /psp/v1/events100ms200ms500ms

7.3 Uptime SLA

  • Year 1: 99.5% (≤3.6 jam downtime/bulan)
  • Year 2+: 99.9% (≤43 menit downtime/bulan)

7.4 Retry Strategy (untuk payment.kesles.com client)

Untuk error transient (500, 502, 503, 504, timeout):

  • Exponential backoff: 1s, 2s, 4s, 8s (max 4 retry)
  • Max timeout per attempt: 10 detik
  • Kalau masih fail: queue ke dead letter → alert on-call

Untuk event forwarding (/psp/v1/events, /psp/v1/settlements) yang critical:

  • Durable queue di PSP side (Redis Streams atau NATS JetStream)
  • Retry indefinite sampai kesles_merchant return 200
  • Idempotency key mencegah double-processing

7.5 Response Field Exclusions — Internal-Only Fields

merchant_tier is NOT exposed di response PSP API. Field ini internal-only untuk kesles_merchant analytics + admin dashboard — bukan untuk PSP consumption.

Rationale:

  • payment.kesles.com punya DB merchant_tier sendiri (master MDR logic)
  • Double source of truth = risk drift + inconsistent billing
  • kesles_merchant hanya trust MDR amounts yang PSP kirim via POST /psp/v1/events (event_type: transaction.*)

Field yang SEDIA di response PSP:

  • merchant_name, nmid, bank_code, category_code (MCC reference)
  • phone, email (contact info untuk notifikasi kritis)
  • address.* (fulfillment + verification)
  • status, kyc_status, kyc_verified_at
  • qris_static_payload, qris_static_url_image (kalau perlu re-display)

Field yang TIDAK di-expose ke PSP:

  • merchant_tier (REGULER/MICRO/EDUCATION/dll) — internal BI tier classification
  • business_scale_* (MIKRO/KECIL/MENENGAH) — internal analytics
  • average_monthly_revenue, employee_count — privacy + competitive info
  • referral_* — internal partner attribution
  • device_price_amount, shipping_fee_amount, promo_amount — pricing commercial

Kalau PSP butuh informasi di atas untuk use case spesifik, coordinate via product request → evaluate case-by-case.


7.6 Push Notification Flow (Event Receiver → Mobile App)

payment_service tidak duplicate FCM code. Push notification handled via existing infrastructure di merchant_core_api (active runtime backend yang sudah punya Firebase Admin SDK credentials + device token store + FCM HTTP v1 client).

Arsitektur 3-hop:

payment.kesles.com payment_service (8085) merchant_core_api FCM
│ │ │ │
│──POST /psp/v1/events ──►│ verify HMAC (X-PSP-Key-ID) │ │
│ │ INSERT payment.transactions │ │
│ │ UPSERT psp.event_log │ │
│ │ │ │
│ │──POST /internal/ ►│ │
│ │ transaction-status │ │
│ │ (X-Internal-API-Key) │ │
│ │ │ │
│ │ │── Upsert gateway │
│ │ │ status │
│ │ │── Lookup device │
│ │ │ tokens │
│ │ │── push.Client.Send │
│ │ │ │──► FCM API
│ │ │ │ to mobile
│ │ │ │
│ │◄── 202 Accepted + summary │ │
│ │ │ │
│◄── 200 OK {ack: true} │ │ │
│ (dashboard-api return SEBELUM push selesai — │
│ call ke core-api jalan async di goroutine) │

Implementation detail:

  • Dashboard-api forwarder: services/dashboard_api/internal/app/psp_push_notifier.go

    • Call POST {CORE_API_BASE_URL}/internal/transaction-status dengan header X-Internal-API-Key: {CORE_API_INTERNAL_KEY}
    • Async via goroutine dengan 10s timeout
    • Tidak block response ke PSP
    • Log outcome (notif_attempted, notif summary) untuk observability
  • Core-api push handler: merchant_core_api/internal/httpapi/internal_transaction_status_handlers.go

    • Endpoint POST /internal/transaction-status (existing, production)
    • Lookup active push tokens dari notification.fcm_push_tokens via FindActivePushTokensByMerchantID
    • Send FCM HTTP v1 via push.Client.Send()
    • Auto-deactivate invalid/expired tokens (push.IsInvalidTokenError)
    • Return push summary (sent_count, failed_count, invalidated_count)
  • FCM config (existing di merchant_core_api):

    • Env: FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL, FIREBASE_PRIVATE_KEY
    • Service account JSON: kesles-merchant-firebase-adminsdk-fbsvc-66b18e8bf0.json

Rationale pakai service-to-service (bukan direct FCM dari dashboard-api):

  1. Single source of truth — 1 credentials Firebase, 1 device token store, tidak ada drift
  2. Reuse existing codepush.Client, FindActivePushTokensByMerchantID, IsInvalidTokenError sudah battle-tested
  3. Consistent notification templatebuildTransactionNotificationCopy di core-api menentukan title + body
  4. Token lifecycle — invalid token auto-deactivate (core-api handle, dashboard-api tinggal forward)
  5. Architecturally clean — dashboard-api jadi PSP integration layer, core-api tetap owner of mobile-facing concerns

Payload mapping dari PSP event → core-api internal format:

PSP fieldCore-api fieldNotes
payload.reference_numberreferenceNo
payload.status00/01responseCodesuccess=00, failed=01
payload.merchant_idmerchantId
payload.outlet_id / fallback NMIDterminalIdrequired by validation
payload.transaction_idpartnerReferenceNoPSP external id
payload.amount (string)amount
payload.rrnapprovalCode
payload.statusstatussuccess/refunded/failed

8. Active-Only Filter Rules

Critical: Semua endpoint /api/psp/v1/* yang return merchant data HANYA expose merchant dengan filter:

WHERE status = 'active'
AND deleted_at IS NULL
AND nmid IS NOT NULL

Merchant dengan status pending, inactive, suspended, deleted tidak terlihat oleh PSP — endpoint return 404 merchant_not_found.

Handling Status Transitions

Event receiver (/psp/v1/events) tetap accept event untuk merchant non-active tapi:

  • Log event ke psp.event_log
  • Tidak insert ke merchant.transactions (treat sebagai orphan)
  • Tidak forward push notif ke mobile
  • Return 200 OK (ack) + field action: "logged_only", reason: "merchant_not_active"

Ini handle race condition: merchant baru di-suspend, tapi transaksi late-arrival masih di-forward oleh PSP.

Outbound Events dari kesles_merchant

Setiap status change trigger outbound call ke payment.kesles.com:

Status TransitionOutbound Call
pendingactivePOST payment.kesles.com/api/psp/v1/merchants (register)
activesuspendedPOST .../merchants/{id}/suspend
activeinactivePOST .../merchants/{id}/deactivate
suspendedactivePOST .../merchants/{id}/reactivate
any → deletedPOST .../merchants/{id}/terminate

9. Event Idempotency

9.1 Idempotency Key

Event receiver wajib cek X-Idempotency-Key (atau external_event_id dari payload) terhadap psp.event_log.external_event_id:

SELECT id, processing_status, processed_at
FROM psp.event_log
WHERE external_event_id = $1
LIMIT 1;

Kalau exist → return HTTP 200 {"status":"duplicate_skipped"}. Jangan re-process.

9.2 Storage

INSERT INTO psp.event_log (
external_event_id,
event_type,
merchant_id,
payload,
signature_verified,
received_at,
processing_status
) VALUES ($1, $2, $3, $4, $5, now(), 'pending')
ON CONFLICT (external_event_id) DO UPDATE
SET processing_status = 'duplicate_skipped'
RETURNING id;

9.3 Processing Status Flow

pending ──► success (happy path)
╲─► failed (error di handler, retry)
╲─► duplicate_skipped (event sudah di-process sebelumnya)

10. Implementation Checklist

catatan

Nomor migration di bawah adalah yang aktual di merchant_database/db_kesles_merchant/migrations/ — draft awal mencantumkan 066/067/068 yang merupakan migration tidak terkait. Semua migration PSP sudah ter-apply. Verified 2026-05-06.

Backend kesles_merchant

  • Apply migration 062_psp_integration_schema.sql (schema psp + psp.api_keys + psp.event_log + psp.outbound_requests + view psp.v_active_merchants + kolom NMID/QRIS di merchant.merchants)
  • Apply migration 068_add_mid_tid_and_tighten_nmid.sql (kolom MID/TID + NMID NOT NULL hardening)
  • Apply migration 069_nmid_prefix_km.sql, 070_psp_view_with_terminal_and_qris_codes.sql, 072_psp_view_add_bank_account_fields.sql (extend psp.v_active_merchants)
  • Apply migration 087_partner_psp_role_and_hmac.sql (multi-tenant: partner.api_credentials.auth_method='hmac' + partners.psp_role untuk Tier 2 PSP External)
  • Apply migration 092_chk_merchant_nmid_bank_pair.sql + 096_drop_terminal_psp_duplicates.sql (data-integrity guards)
  • services/dashboard_api/internal/app/psp_auth_middleware.go — HMAC verify, dual-source lookup (psp.api_keys + partner.api_credentials), IP allowlist, 60s in-memory cache ✅ live
  • services/dashboard_api/internal/app/psp_merchants.go — 8 lookup endpoint ✅ live (end-to-end tested)
  • services/dashboard_api/internal/app/psp_event_receiver.go — 4 event endpoint ✅ live (transaction path tested)
  • services/dashboard_api/internal/app/psp_crypto.go — AES-256-GCM envelope enc:v1: untuk hmac_secret_encrypted ✅ live
  • services/dashboard_api/internal/app/psp_push_notifier.go — FCM push via service-to-service ke merchant_core_api /internal/transaction-status ✅ live
  • services/dashboard_api/internal/app/psp_outbound_client.go — outbound HTTP client ke payment.kesles.com, 7 method typed (RegisterMerchant, UpdateMerchant, SuspendMerchant, ReactivateMerchant, TerminateMerchant, InitiateRefund, LookupTransaction) plus ReplayRequest. Mode-gated via env PSP_OUTBOUND_MODE (default disabled); lihat §5.0 untuk tabel mode lengkap. Lifecycle dispatcher (psp_outbound_triggers.go) sudah di-wire ke flow approve / PATCH / DELETE merchant. Audit log (psp.outbound_requests) auto-populated tiap attempt. Admin panel route (Integrations → PSP Outbound, super_admin) live — lihat §5.8. Belum di-wire: handler refund di dashboard belum ada, jadi InitiateRefund baru bisa diakses lewat dev tester / replay
  • services/dashboard_api/internal/app/psp_outbound_audit.go + psp_outbound_triggers.go + dashboard_psp_outbound_admin.go — audit store, lifecycle dispatcher (transisi status → method outbound), 4 endpoint observability super-admin (/status, /audit, /audit/{id}, /audit/{id}/replay)
  • Per-device list — fully done (2026-06-09): (1) endpoint GET /api/psp/v1/merchants/{id}/devices (§3.6.5); (2) DTO Device + DeviceSummary + DeviceListResponse di packages/psp_integration_go/types.go; (4) view psp.v_active_merchant_devices (mig 126) — di-DROP mig 101, pspListDevices route ke corePSPClient atau fallback inventoryClient; (5) include_devices=true DONE 2026-06-09 — wired ke §3.2/§3.3/§3.4/§3.5 + MerchantDetailResponse.Devices field ditambah; (6) Outlet.device_count DONE 2026-06-09 — real count via inventory_service HTTP; (7) reverse-lookup GET /api/psp/v1/merchants/by-serial/{serial_number} + DTO MerchantBySerialResponse (§3.6.6). Masih pending: (3) DTO Merchant belum expose DeviceModel/DeviceStatus inline snapshot di §3.2 response body.
  • Multi-outlet schema (Phoenixdart enterprise use case) — proposal di docs/database/plans/multi-outlet-enterprise-schema-proposal.md. Akan pindah NMID/MID ke level outlet, TID ke level device. Affect kontrak ini: response lookup §3.x tambah outlet_id + tid per-device; resolution transaction event §4 jadi 2-step (outlet → device). Schedule: effort 4-6 minggu, update kontrak in lockstep dengan implementation.
  • Registration di dashboard_api/server.go untuk route /api/psp/v1/merchants, /api/psp/v1/merchants/ (di-wrap pspAuthMiddleware); dan di payment_service routes /psp/v1/events, /psp/v1/settlements (di-wrap PSP HMAC middleware Scheme B)
  • Admin UI di dashboard untuk manage API Keys (create, list, revoke, rotate). Saat ini cuma ada panel dev psp-tester untuk super-admin smoke test — tidak ada halaman CRUD untuk psp.api_keys. Operator rotate key via SQL + cmd/psp-encrypt
  • Unit test + integration test minimal 70% coverage. Saat ini hanya packages/psp_integration_go/signing_test.go yang ada — tidak ada psp_*_test.go di services/dashboard_api/
  • Load test — verify p95 target (tidak ada test artefact di repo)

Shared Go Package

  • Create packages/psp_integration_go/
  • signing.go — HMAC signing + verification
  • types.go — shared DTOs (Merchant, Transaction, Settlement, etc.)
  • client.go — HTTP client wrapper dengan auto-signing
  • Unit test (signing_test.go) — signing only; client + types belum ter-cover
  • Consumed via go.mod replace directive dari services/dashboard_api/go.mod (monorepo pattern — git-tag publishing N/A untuk consumer internal-only)

Documentation

  • File ini (psp-integration-contract.md)
  • Postman collection (merchant_docs/api_docs/internal/static/postman/kesles-psp-integration.postman_collection.json + kesles-psp-env-local.postman_environment.json)
  • Onboarding guide untuk tim payment.kesles.com. merchant_docs/docs/plans/phase2-ops-handoff.md adalah DevOps handoff untuk DB ops, bukan doc onboarding PSP integrator. Section 2.7 di kontrak ini describe process credential-handover tapi standalone onboarding/quick-start belum ada
  • Runbook operational: runbooks/master-key-rotation.md (AES master key), runbooks/secret-rotation.md (per-key rotation), runbooks/incident-response.md, runbooks/setup-api-merchant-subdomain.md (masih pending production execution — lihat header runbook)

Admin Operations

  • Script generate + distribute API key pertama ke tim payment.kesles.com. Saat ini cuma ada services/dashboard_api/cmd/psp-encrypt (encrypt plaintext secret ke AES-GCM) dan cmd/psp-sign-post (signing helper) — tidak ada command generate-key yang mint fresh key_id + secret pair, insert ke psp.api_keys, dan print handover packet. Operator saat ini script ini manual
  • Monitoring dashboard untuk rate limit + signature failure rate
  • Alerting untuk anomaly (spike 401, spike 5xx, rate limit hit)

Production exposure (subdomain)

  • [~] Deploy api-merchant.kesles.com vhost — lihat runbooks/setup-api-merchant-subdomain.md. Re-probed 2026-05-18: vhost & TLS cert sudah benar (CN=api-merchant.kesles.com, Let's Encrypt E7), HTTP→HTTPS 301 OK, backend handler live di port 8082 (loopback curl /api/psp/v1/merchants401 missing_auth_headers ✅). Tapi nginx proxy_pass config masih salah: trailing slash strip prefix /api/psp/v1 sebelum forward, jadi backend terima path /merchants dan return 404 route not registered: /merchants. Same issue untuk /api/partner/v1/. Root / masih serve static HTML 200 alih-alih return 404. Fix: edit vhost ke proxy_pass http://localhost:8082; (tanpa trailing slash), proxy_pass http://localhost:8080; untuk Partner, replace body location / dengan return 404 '{"error":"not_found"}', reload nginx, re-probe 8 cek.

11. Open Questions untuk Tim payment.kesles.com

Sebelum implement, align dengan tim payment.kesles.com:

  1. Endpoint payment.kesles.com spec — mereka akan expose endpoint apa saja? (untuk outbound kesles_merchant call)
  2. IP payment.kesles.com — static IP apa yang akan di-allowlist?
  3. Error retry coordination — kalau kesles_merchant down 10 menit, berapa lama PSP buffer event sebelum drop?
  4. Data reconciliation — berapa sering (harian/mingguan) run compare data kedua DB untuk detect drift?
  5. Monitoring akses — apakah ada shared observability tool (Grafana/Datadog) untuk kedua service?

11.5 Integration Status & Handover (23 April 2026)

✅ Production-Ready Components

ComponentStatusFile/Location
Database schema (2 DBs)✅ Applied to stagingMigration 011, 012, 062, 063
Go shared package signing✅ 7 tests passpackages/psp_integration_go/
HMAC auth middleware✅ 4 scenarios testedpsp_auth_middleware.go
PSP lookup handlers (8)✅ End-to-end testedpsp_merchants.go
Event receiver handlers (4)✅ Transaction path testedpsp_event_receiver.go
AES-256-GCM secret encryption✅ Integrated + testedpsp_crypto.go
CLI encrypt tool✅ Works (jalur legacy untuk psp.api_keys)cmd/psp-encrypt/main.go
Push notification forwarder✅ Service-to-service ke merchant_core_api /internal/transaction-status (FCM sudah live)psp_push_notifier.go
Auth credential multi-tenant (Tier 1 internal + Tier 2 bank + Tier 3 partner)✅ Live, dual-source (psp.api_keys + partner.api_credentials)psp_auth_middleware.go §lookupSecretFromPartnerCredential
Admin UI lifecycle credential✅ Live di dashboard, Master Data → Partner → API Credentials (super_admin only)dashboard_partner_credentials.go (list / create / rotate / revoke)
Dashboard PSP Endpoint Tester (loopback)✅ Live, sign HMAC di server-side, secret tidak ke browserdashboard_dev_psp_tester.go
PSP Outbound client (kesles_merchant → payment.kesles.com)✅ Live, dispatch OFF default (mode=disabled). Trigger point sudah di-wire ke flow approve / suspend / reactivate / terminate / update / delete merchant. Audit log tulis ke psp.outbound_requests. Aktivasi plug-and-play via 4 env vars + restart, tanpa code change.psp_outbound_client.go, psp_outbound_audit.go, psp_outbound_triggers.go
Admin panel — Integrations → PSP Outbound✅ Live, super_admin only. 4 endpoint (status / audit list / audit detail / replay).dashboard_psp_outbound_admin.go
Postman collection✅ With HMAC pre-request script/postman/kesles-psp-integration.postman_collection.json (di merchant_docs/api_docs/internal/static/postman/)

⏳ Pending (Required Sebelum Full Production)

  1. FCM / APNs credentialsSOLVED via service-to-service call — dashboard-api forward event ke merchant_core_api /internal/transaction-status yang sudah punya Firebase Admin SDK + device token store + push.Client live. Hanya pastikan:

    • Core-api env vars terisi: FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL, FIREBASE_PRIVATE_KEY
    • Dashboard-api env vars: CORE_API_BASE_URL, CORE_API_INTERNAL_KEY — sudah ada di .env.development
    • Device token registration flow via mobile app → core-api notification.fcm_push_tokens — sudah implemented
  2. Refund push notification — core-api belum punya endpoint /internal/refund-status yang trigger FCM untuk refund. Saat ini dispatchRefundPush log-only. Future: tambah endpoint di core-api atau extend /internal/transaction-status dengan type=refund.

  3. IP allowlist production — saat ini NULL di dev. Set allowed_ip_ranges per API key saat deploy (static IP payment.kesles.com harus di-provide)

  4. Admin UI API key managementDONE — Flutter admin panel sudah live di Master Data → Partner → API Credentials (super_admin only). Backend handler di dashboard_partner_credentials.go: list, create (auto-generate credential_key + 32-byte secret, AES-GCM-encrypt pakai KESLES_SECRET_ENCRYPTION_KEY, return plaintext sekali), rotate, revoke. Policy default: HMAC auth_method='hmac', scope dipilih per credential. Plaintext secret tidak pernah round-trip setelah create; subsequent read hanya ada hash + ciphertext.

  5. Master key secret rotation playbook — dual-key support saat rotate (grace period 7 hari)

  6. Fix nginx vhost proxy config di api-merchant.kesles.com — vhost nginx + cert SAN sudah LIVE per re-probe 2026-05-18 (CN=api-merchant.kesles.com, Let's Encrypt E7). HTTP→HTTPS 301 OK. Backend dashboard_api versi lengkap (psp_merchants.go + psp_event_receiver.go + psp_auth_middleware.go) sudah running di port 8082 — loopback curl http://127.0.0.1:8082/api/psp/v1/merchants return 401 missing_auth_headers JSON ✅. Tapi probe via subdomain return 404 {"error":"not_found","message":"route not registered: /merchants"} — backend laporkan path yang diterima = /merchants (bukan /api/psp/v1/merchants). Root cause: nginx proxy_pass di location /api/psp/v1/ punya trailing slash sehingga strip prefix sebelum forward. Sama issue di /api/partner/v1/ → port 8080. Plus location / masih serve static HTML 200 (semestinya return 404 JSON). Action item: edit /etc/nginx/sites-available/api-merchant.kesles.com.confproxy_pass http://localhost:8082; (TANPA trailing slash) untuk PSP dan port 8080 untuk Partner, ganti location / jadi return 404 '{"error":"not_found"}', nginx -t && systemctl reload nginx, re-probe 8 cek. Detail di runbook setup-api-merchant-subdomain.md §Deployment status.

📋 Handover Info untuk Tim payment.kesles.com

Alur issuance credential (per kebijakan PT Kesles 2026-04-30): Semua credential baru diterbitkan via UI dashboard Master Data → Partner → API Credentials (super_admin only) di bawah partner row KESLES-PAYMENT (psp_role='internal'). Tabel legacy psp.api_keys deprecated untuk credential baru.

Template hand-over credential (share via secure channel — 1Password / Bitwarden, JANGAN plain email/Slack):

credential_key: kesles-payment-internal-<hex-dari-rotate>
credential_secret: <plaintext muncul sekali saat create/rotate — copy segera, tidak bisa diambil lagi>
auth_method: hmac
allowed_scopes: merchants:read, transactions:read, transactions:write,
settlement:read, qris_identifiers:read, kyc_pii:read
expires_at: (set sesuai partner agreement; default 90 hari)
allowed_ip_ranges: (NULL untuk dev/SIT; isi dengan static IP partner saat prod)

Public base URL kesles_merchant API:

Catatan terminologi 2 domain mirip
  • payment.kesles.comProduction — nama service tim recipient (Tier 1 PSP Internal Kesles), Go backend live. Outbound target dari kesles_merchant.
  • banking.kesles.comDevelopment — web app "Mandiri API Transfer Test" (Express.js), dev environment milik tim payment.kesles.com untuk caller-side testing. Bukan URL kesles_merchant. Tidak perlu di-call dari sisi kesles_merchant.
EnvironmentURLStatus (per 2026-05-18, actual probe)
Local dev (internal Kesles)Lookup: http://127.0.0.1:8082 · Events: http://127.0.0.1:8085Akses via VPN/port-forward. Loopback curl /api/psp/v1/merchants → 401 missing_auth_headers
Staginghttps://api-merchant-staging.kesles.combelum di-provision
Productionhttps://api-merchant.kesles.comDNS resolve ✅ · cert SAN ✅ · nginx vhost ✅ LIVE · HTTP→HTTPS 301 ✅ · routing /api/psp/v1/:8082 ✅ · routing /psp/v1/:8085 ✅ · semua aktif di VM per 2026-06-07

Integration Steps untuk Tim payment.kesles.com:

  1. Terima shared Go package psp_integration_go via salah satu metode. Module ID: git.kesles.com/merchant/packages/psp_integration_go (konsisten dengan pattern service Kesles lain).

    • Vendor zip (rekomendasi sekarang) — tim Kesles kirim folder via 1Password Send / Signal DM, extract ke vendor/psp_integration_go/, tambah require + replace directive di go.mod tim Anda ke path lokal tersebut. Module ID git.kesles.com/... di-resolve via replace.
    • Source paste — copy 3 file (signing.go, types.go, client.go) langsung ke project Anda. Trade-off: manual sync saat ada update.
    • Future: git.kesles.com adalah aspirational identifier — server git belum di-setup live. Kalau nanti Kesles deploy Gitea/Forgejo/GitLab CE di git.kesles.com, tinggal hapus replace directive → go get git.kesles.com/merchant/packages/psp_integration_go@latest langsung work tanpa rename apapun.
  2. Terima credentials via secure channel (1Password Send / Signal DM)

  3. Set env vars: KESLES_MERCHANT_KEY_ID, KESLES_MERCHANT_HMAC_SECRET, KESLES_MERCHANT_BASE_URL

  4. Instantiate client:

    import pspintegration "git.kesles.com/merchant/packages/psp_integration_go"

    client := pspintegration.NewClient(pspintegration.ClientConfig{
    BaseURL: os.Getenv("KESLES_MERCHANT_BASE_URL"), // local dev: http://127.0.0.1:8082 (via VPN/port-forward); prod: https://api-merchant.kesles.com
    KeyID: os.Getenv("KESLES_MERCHANT_KEY_ID"),
    Secret: os.Getenv("KESLES_MERCHANT_HMAC_SECRET"),
    })
  5. Call endpoints:

    // Saat bank callback masuk dengan NMID
    m, err := client.GetMerchantByNMID(ctx, "ID102326873XXXX")

    // Forward transaction event
    _, err := client.ForwardTransactionEvent(ctx, envelope, eventID)
  6. Test handshake — coordinate dengan tim kesles_merchant untuk baseline test. Pilihan:

    • Local via VPN/port-forward: GET http://<vpn-tunnel>:8082/api/psp/v1/merchants?limit=1 setelah backend Go dashboard_api running di server dev (akan return 401 kalau HMAC kurang, 200 + JSON kalau HMAC valid)
    • Production sandbox (setelah PSP handler deploy ke api-merchant.kesles.com): GET https://api-merchant.kesles.com/api/psp/v1/merchants?limit=1 dengan dummy credential untuk verify routing

12. Versioning & Deprecation

12.1 Version Strategy

  • Current: /api/psp/v1/* (explicit v1, ditetapkan 2026-05-07)
  • Breaking change: /api/psp/v2/* dengan 6 bulan deprecation notice
  • Additive changes (field baru) tidak butuh versi baru

12.2 Communication

Perubahan contract harus di-announce di:

  • Changelog doc di repo
  • Slack channel shared 2 tim
  • Email formal kalau breaking

13. Referensi

  • Mobile API contract: merchant-mobile-api-contract.md
  • Mobile app spec: apps/mobile_user/mvp-spec.md (merchant_docs/docs/apps/mobile_user/mvp-spec.md)
  • Analytics spec: merchant-financial-analytics-spec.md (to be created)
  • Migration 066-068: to be created
  • Shared Go package: packages/psp_integration_go/ (to be created)
  • HMAC standard: RFC 2104
  • Stripe webhook signing (reference pattern): https://stripe.com/docs/webhooks/signatures

Summary

Contract API 15 endpoint (9 lookup + 4 event + 2 sync callback) + 7 outbound endpoint. Security: HMAC-SHA256 + IP allowlist + audit log. Active-only filter untuk semua merchant data ke PSP. Idempotent + retry-safe.

Target ready: Batch 1 (spec + migration + shared package) end of Month 1. Backend handler implementation: Month 2. Integration testing dengan tim payment.kesles.com: Month 3.