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).
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:
- Insert row di
partner.partners(psp_role='bank', psp_bank_code='BMRI'/'BRIN'/'BBNI'/dst). - Insert row di
partner.api_credentials(auth_method='hmac',hmac_secret_encryptedvia psp-encrypt CLI, optionalallowed_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
| Aspek | kesles_merchant | payment.kesles.com |
|---|---|---|
| Owner data | Merchant profil, KYC, outlet, user, analytics, revenue, profit | Transaksi QRIS master, settlement, QRIS payload, bank credentials |
| Publik-facing | Mobile app merchant + web dashboard | H2H ke bank, customer payment page |
| DB schema | merchant.*, psp.*, db_reference.* | payment.* (own DB) |
| Public API | api-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
- Idempotent — semua operasi bisa diulang tanpa side effect duplikat (via
X-Idempotency-Key) - Active-only exposure — kesles_merchant hanya expose merchant dengan
status = 'active' AND deleted_at IS NULL AND nmid IS NOT NULLke PSP - Audit log semua request — di
psp.event_logdanpsp.outbound_requests - Retry-safe — kedua service harus handle retry dengan backoff exponential
- 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/settlements → payment_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
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-Timestampheader) - Body kosong untuk GET → trailing newline tetap ada
- Source:
packages/psp_integration_go/signing.go—BuildStringToSign()
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-Timestampheader, verbatim) - Path tanpa query string (event endpoint tidak punya query params)
- Source:
payment_service/internal/payment/transport/http/psp_auth.goline 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):
- Extract
X-API-Key-ID→ lookup secret daripsp.api_keys(legacy) ataupartner.api_credentialsdidb_kesles_merchant(decrypt via AES-256-GCM) - Check timestamp freshness — reject kalau
now - X-Timestamp > 300detik - Re-compute HMAC, compare dengan
X-Signatureviahmac.Equal()(constant-time) - Check IP allowlist (optional):
partner.api_credentials.allowed_ip_ranges - Rate limit per API Key ID
Scheme B (Event Receiver — payment_service):
- Extract
X-PSP-Key-ID→ lookup secret daripsp.api_keysdidb_kesles_merchant_payment(decrypt via AES-256-GCM, key:PSP_HMAC_ENCRYPTION_KEY) - Parse
X-PSP-Timestampsebagai RFC3339 → reject kalau|now - parsedTS| > 300detik - Re-compute HMAC dari
METHOD\nPATH\nTIMESTAMP_STRING\nBODY, compare denganX-PSP-Signature(plain hex, constant-time) - Check IP allowlist (optional):
psp.api_keys.allowed_ip_ranges - 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_atdipartner.api_credentials. Automated reminder di UI admin BELUM diimplementasi — operator harus track expiry secara manual (calendar reminder ~7 hari sebelumexpires_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)
- Prefix
-
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:
- Generate new master key:
openssl rand -base64 32 - Deploy app dengan dual-key support — try new key first, fallback ke old (tidak covered di implementation saat ini — future enhancement)
- Re-encrypt semua rows di
psp.api_keysdengan key baru - 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:
- Admin generate API key di Admin UI (atau manual SQL insert untuk MVP)
- System output plaintext secret SATU KALI — tidak bisa di-recover
- 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
- Tim payment.kesles.com store di env var mereka (
KESLES_MERCHANT_HMAC_SECRET) - Rotation notification dikirim 7 hari sebelum expire (
expires_atdipsp.api_keys)
2.8 Audit Log Requirements
Semua request inbound wajib di-log:
- Lookup API (
/api/psp/v1/*): dipsp.outbound_requests(dashboard_api,db_kesles_merchant) - Event Receiver (
/psp/v1/events,/psp/v1/settlements): dipsp.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:
| Param | Type | Default | Description |
|---|---|---|---|
limit | int | 100 | Max 500 |
cursor | string | - | Opaque pagination cursor |
updated_since | datetime (ISO8601) | - | Delta sync — hanya return merchant yang updated_at ≥ value |
bank_code | string | - | Filter per PSP bank (BMRI, BCA, dll) |
category_code | string | - | Filter MCC category |
include_outlets | bool | false | Include 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 outletsinclude_owners(bool, default false) — include owner/manager usersinclude_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 dedicatedGET /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 matchemail— exact match (case-insensitive)npwp— exact matchnmid— 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
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_status | Internal payment_terminal_inventory.status (mig 024 + 035) |
|---|---|
active | active, online |
inactive | stock, 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.
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:
| Tier | Caller (psp_role) | Row scoping | Field serial_number |
|---|---|---|---|
| 1 | internal (payment.kesles.com) | tidak ada (semua merchant) | visible |
| 2 | bank | WHERE bank_code = caller_bank_code | visible |
| 3 | none (Tier-3 partner / unauth) | per-context | empty (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}
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-00001atauAISINO-DEV-00421di-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):
- View by SN — pakai unique partial index
uk_payment_terminal_inventory_serial_number(mig 024 + 035). merchantsby 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_numberkosong.
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}
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}
IMPLEMENTED. Lookup merchant berdasarkan merchant_code Kesles. Ini adalah referensi kanonik PSP ke merchant Kesles setelah registrasi.
Path params:
merchant_code— kode merchant Kesles, formatMRC-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 B — X-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
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_type | Routing internal |
|---|---|
transaction.success, qris.payment.success | Flow A (merchant normal) atau Flow B (NMID korporat) — transaksi berhasil |
transaction.failed, qris.payment.failed | Transaksi gagal — log ke psp.event_log, push notif ke merchant |
transaction.pending | Transaksi dalam proses / menunggu konfirmasi bank |
transaction.cancelled | Transaksi dibatalkan — update status transaksi |
transaction.reversed | Transaksi di-reverse oleh bank |
*.refunded, transaction.refunded, qris.payment.refunded | Refund completion flow |
event mengandung nmid_status | NMID status change flow |
Headers extra:
X-Idempotency-Key— required, unique per event (biasanya pakaiexternal_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:
- Verify HMAC (Scheme B:
X-PSP-Key-ID/X-PSP-Signature) - Check
external_event_idterhadappsp.event_log.external_event_id— duplicate → return 200{"status":"duplicate_skipped"}tanpa re-process - Route berdasarkan
event_type(transaction / refund / nmid_status) - Transaction flow: INSERT
payment.transactions, async FCM push ke merchant - Refund flow: update local refund state →
completed, mark transactionrefunded - NMID status flow: UPDATE merchant status + push notif ke owner
- Log ke
psp.event_log(db_kesles_merchant_payment) - Return 200
Errors:
- 401
missing_headers/invalid_signature/timestamp_expired - 422
invalid_payload—external_event_idatauevent_typekosong - 500 kalau
PSP_HMAC_ENCRYPTION_KEYtidak 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 clientPSPOutboundClientdengan 7 method typed:RegisterMerchant,UpdateMerchant,SuspendMerchant,ReactivateMerchant,TerminateMerchant,InitiateRefund,LookupTransaction. HMAC signing via sharedpackages/psp_integration_go.Signer(string-to-sign sama dengan verifier inbound).services/dashboard_api/internal/app/psp_outbound_audit.go— wrapper di atas tabelpsp.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.gohandleMerchantRegistrationApprove) →RegisterMerchant - PATCH merchant (
dashboard_merchants.goupdateMerchantHandler) →Suspend/Reactivate/Terminate/Updatetergantung transisi status - DELETE merchant (
dashboard_merchants.godeleteMerchantHandler) →TerminateMerchant
- flow approve (
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):
| Mode | Side effect |
|---|---|
disabled (default) | No-op: handler short-circuit di IsEnabled() check. Tidak alokasi, tidak write DB, tidak HTTP. |
audit_only | Sign + 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. |
dispatch | Produksi 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_api — tidak 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 sumber | Fire method | Path (di payment.kesles.com) |
|---|---|---|
| Approve registrasi merchant | RegisterMerchant | POST /api/internal/merchants |
PATCH status active→suspended | SuspendMerchant | POST /api/internal/merchants/{id}/suspend |
PATCH status suspended→active | ReactivateMerchant | POST /api/internal/merchants/{id}/reactivate |
PATCH status *→inactive | TerminateMerchant | POST /api/internal/merchants/{id}/terminate |
| PATCH detail (non-status, mis. NPWP/alamat/kontak) | UpdateMerchant | PATCH /api/internal/merchants/{id} |
| DELETE merchant (soft-delete) | TerminateMerchant | POST /api/internal/merchants/{id}/terminate |
| Operator klik Refund (handler TBD) | InitiateRefund | POST /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/):
| Method | Path | Fungsi |
|---|---|---|
| GET | /status | Return { 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}/replay | Re-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 > 0di/auditquery untuk lihat replay. - Replay mode-aware: di
audit_onlytulis row baru dengan notedispatch_disabledtapi tetap tidak HTTP; didispatchre-sign + re-send. - Idempotensi antar attempt = tanggung jawab payment.kesles.com (de-dupe by
X-Request-IDdalam 5-menit replay window).
Status grouping (dipakai filter status=):
success—response_status BETWEEN 200 AND 299failed—response_statusnon-2xx atau NULL denganerror_message IS NOT NULLpending—response_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
| HTTP | Error Code | Meaning |
|---|---|---|
| 400 | invalid_request | Malformed JSON atau query param |
| 400 | invalid_cursor | Cursor tidak valid |
| 400 | invalid_nmid_format | NMID tidak sesuai format BI |
| 401 | unauthorized | Missing auth headers |
| 401 | invalid_signature | HMAC verification failed |
| 401 | timestamp_expired | Timestamp > 5 menit old |
| 401 | api_key_revoked | API Key sudah di-revoke |
| 403 | ip_not_allowed | IP caller tidak di allowlist |
| 403 | endpoint_not_allowed | API Key tidak boleh akses endpoint ini |
| 404 | merchant_not_found | Merchant tidak ada atau non-active |
| 404 | outlet_not_found | |
| 409 | duplicate_resource | Unique constraint violation |
| 409 | nmid_already_assigned | Merchant sudah punya NMID lain |
| 422 | validation_failed | Input validation fail |
| 429 | rate_limit_exceeded | Exceeded per-API-key rate limit |
| 500 | internal_error | Unexpected server error (detail di-log, tidak di-return) |
7. Rate Limiting & Performance SLA
7.1 Per API Key Rate Limits
| Endpoint Pattern | Rate 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/search | 300 req/menit |
GET /api/psp/v1/merchants/{id}/devices | 300 req/menit |
GET /api/psp/v1/merchants/{id}/outlets | 300 req/menit |
GET /api/psp/v1/merchants/{id}/owners | 300 req/menit |
GET /api/psp/v1/reference/* | 100 req/menit |
PATCH /api/psp/v1/merchants/{id}/nmid-assignment | 60 req/menit |
POST /psp/v1/events | 6000 req/menit (100/detik) |
POST /psp/v1/settlements | 600 req/menit |
Response headers:
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 543
X-RateLimit-Reset: 1716452640
7.2 Performance SLA
| Endpoint | p50 | p95 | p99 |
|---|---|---|---|
GET /merchants/by-nmid/{nmid} | 30ms | 50ms | 100ms |
GET /merchants/by-mid/{mid} | 30ms | 50ms | 100ms |
GET /merchants/by-code/{merchant_code} | 30ms | 50ms | 100ms |
GET /merchants/by-serial/{serial_number} | 30ms | 50ms | 100ms |
GET /merchants/{id} | 50ms | 100ms | 200ms |
GET /merchants (list limit=100) | 150ms | 300ms | 500ms |
GET /merchants/{id}/outlets | 50ms | 100ms | 150ms |
PATCH /merchants/{id}/nmid-assignment | 80ms | 150ms | 300ms |
POST /psp/v1/events | 100ms | 200ms | 500ms |
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_atqris_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-statusdengan headerX-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
- Call
-
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_tokensviaFindActivePushTokensByMerchantID - Send FCM HTTP v1 via
push.Client.Send() - Auto-deactivate invalid/expired tokens (
push.IsInvalidTokenError) - Return push summary (sent_count, failed_count, invalidated_count)
- Endpoint
-
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
- Env:
Rationale pakai service-to-service (bukan direct FCM dari dashboard-api):
- Single source of truth — 1 credentials Firebase, 1 device token store, tidak ada drift
- Reuse existing code —
push.Client,FindActivePushTokensByMerchantID,IsInvalidTokenErrorsudah battle-tested - Consistent notification template —
buildTransactionNotificationCopydi core-api menentukan title + body - Token lifecycle — invalid token auto-deactivate (core-api handle, dashboard-api tinggal forward)
- 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 field | Core-api field | Notes |
|---|---|---|
payload.reference_number | referenceNo | |
payload.status → 00/01 | responseCode | success=00, failed=01 |
payload.merchant_id | merchantId | |
payload.outlet_id / fallback NMID | terminalId | required by validation |
payload.transaction_id | partnerReferenceNo | PSP external id |
payload.amount (string) | amount | |
payload.rrn | approvalCode | |
payload.status | status | success/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 Transition | Outbound Call |
|---|---|
pending → active | POST payment.kesles.com/api/psp/v1/merchants (register) |
active → suspended | POST .../merchants/{id}/suspend |
active → inactive | POST .../merchants/{id}/deactivate |
suspended → active | POST .../merchants/{id}/reactivate |
any → deleted | POST .../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
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(schemapsp+psp.api_keys+psp.event_log+psp.outbound_requests+ viewpsp.v_active_merchants+ kolom NMID/QRIS dimerchant.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(extendpsp.v_active_merchants) - Apply migration
087_partner_psp_role_and_hmac.sql(multi-tenant:partner.api_credentials.auth_method='hmac'+partners.psp_roleuntuk 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 envelopeenc:v1:untukhmac_secret_encrypted✅ live -
services/dashboard_api/internal/app/psp_push_notifier.go— FCM push via service-to-service kemerchant_core_api/internal/transaction-status✅ live -
services/dashboard_api/internal/app/psp_outbound_client.go— outbound HTTP client kepayment.kesles.com, 7 method typed (RegisterMerchant,UpdateMerchant,SuspendMerchant,ReactivateMerchant,TerminateMerchant,InitiateRefund,LookupTransaction) plusReplayRequest. Mode-gated via envPSP_OUTBOUND_MODE(defaultdisabled); 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, jadiInitiateRefundbaru 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) DTODevice+DeviceSummary+DeviceListResponsedipackages/psp_integration_go/types.go; (4)view— di-DROP mig 101,psp.v_active_merchant_devices(mig 126)pspListDevicesroute kecorePSPClientatau fallbackinventoryClient; (5)include_devices=trueDONE 2026-06-09 — wired ke §3.2/§3.3/§3.4/§3.5 +MerchantDetailResponse.Devicesfield ditambah; (6)Outlet.device_countDONE 2026-06-09 — real count viainventory_service HTTP; (7) reverse-lookupGET /api/psp/v1/merchants/by-serial/{serial_number}+ DTOMerchantBySerialResponse(§3.6.6). Masih pending: (3) DTOMerchantbelum exposeDeviceModel/DeviceStatusinline 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 tambahoutlet_id+tidper-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.gountuk route/api/psp/v1/merchants,/api/psp/v1/merchants/(di-wrappspAuthMiddleware); dan dipayment_serviceroutes/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-testeruntuk super-admin smoke test — tidak ada halaman CRUD untukpsp.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.goyang ada — tidak adapsp_*_test.godiservices/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
replacedirective dariservices/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.mdadalah 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 adaservices/dashboard_api/cmd/psp-encrypt(encrypt plaintext secret ke AES-GCM) dancmd/psp-sign-post(signing helper) — tidak ada commandgenerate-keyyang mint freshkey_id+ secret pair, insert kepsp.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.comvhost — lihatrunbooks/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 (loopbackcurl /api/psp/v1/merchants→401 missing_auth_headers✅). Tapi nginxproxy_passconfig masih salah: trailing slash strip prefix/api/psp/v1sebelum forward, jadi backend terima path/merchantsdan return404 route not registered: /merchants. Same issue untuk/api/partner/v1/. Root/masih serve static HTML 200 alih-alihreturn 404. Fix: edit vhost keproxy_pass http://localhost:8082;(tanpa trailing slash),proxy_pass http://localhost:8080;untuk Partner, replace bodylocation /denganreturn 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:
- Endpoint payment.kesles.com spec — mereka akan expose endpoint apa saja? (untuk outbound kesles_merchant call)
- IP payment.kesles.com — static IP apa yang akan di-allowlist?
- Error retry coordination — kalau kesles_merchant down 10 menit, berapa lama PSP buffer event sebelum drop?
- Data reconciliation — berapa sering (harian/mingguan) run compare data kedua DB untuk detect drift?
- Monitoring akses — apakah ada shared observability tool (Grafana/Datadog) untuk kedua service?
11.5 Integration Status & Handover (23 April 2026)
✅ Production-Ready Components
| Component | Status | File/Location |
|---|---|---|
| Database schema (2 DBs) | ✅ Applied to staging | Migration 011, 012, 062, 063 |
| Go shared package signing | ✅ 7 tests pass | packages/psp_integration_go/ |
| HMAC auth middleware | ✅ 4 scenarios tested | psp_auth_middleware.go |
| PSP lookup handlers (8) | ✅ End-to-end tested | psp_merchants.go |
| Event receiver handlers (4) | ✅ Transaction path tested | psp_event_receiver.go |
| AES-256-GCM secret encryption | ✅ Integrated + tested | psp_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 browser | dashboard_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)
-
FCM / APNs credentials✅ SOLVED via service-to-service call — dashboard-api forward event kemerchant_core_api/internal/transaction-statusyang 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
- Core-api env vars terisi:
-
Refund push notification — core-api belum punya endpoint
/internal/refund-statusyang trigger FCM untuk refund. Saat inidispatchRefundPushlog-only. Future: tambah endpoint di core-api atau extend/internal/transaction-statusdengantype=refund. -
IP allowlist production — saat ini NULL di dev. Set
allowed_ip_rangesper API key saat deploy (static IP payment.kesles.com harus di-provide) -
Admin UI API key management✅ DONE — Flutter admin panel sudah live di Master Data → Partner → API Credentials (super_admin only). Backend handler didashboard_partner_credentials.go: list, create (auto-generatecredential_key+ 32-byte secret, AES-GCM-encrypt pakaiKESLES_SECRET_ENCRYPTION_KEY, return plaintext sekali), rotate, revoke. Policy default: HMACauth_method='hmac', scope dipilih per credential. Plaintext secret tidak pernah round-trip setelah create; subsequent read hanya ada hash + ciphertext. -
Master key secret rotation playbook — dual-key support saat rotate (grace period 7 hari)
-
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 curlhttp://127.0.0.1:8082/api/psp/v1/merchantsreturn401 missing_auth_headersJSON ✅. Tapi probe via subdomain return404 {"error":"not_found","message":"route not registered: /merchants"}— backend laporkan path yang diterima =/merchants(bukan/api/psp/v1/merchants). Root cause: nginxproxy_passdilocation /api/psp/v1/punya trailing slash sehingga strip prefix sebelum forward. Sama issue di/api/partner/v1/→ port 8080. Pluslocation /masih serve static HTML 200 (semestinya return 404 JSON). Action item: edit/etc/nginx/sites-available/api-merchant.kesles.com.conf—proxy_pass http://localhost:8082;(TANPA trailing slash) untuk PSP dan port 8080 untuk Partner, gantilocation /jadireturn 404 '{"error":"not_found"}',nginx -t && systemctl reload nginx, re-probe 8 cek. Detail di runbooksetup-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:
payment.kesles.com— Production — nama service tim recipient (Tier 1 PSP Internal Kesles), Go backend live. Outbound target dari kesles_merchant.banking.kesles.com— Development — 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.
| Environment | URL | Status (per 2026-05-18, actual probe) |
|---|---|---|
| Local dev (internal Kesles) | Lookup: http://127.0.0.1:8082 · Events: http://127.0.0.1:8085 | Akses via VPN/port-forward. Loopback curl /api/psp/v1/merchants → 401 missing_auth_headers ✅ |
| Staging | https://api-merchant-staging.kesles.com | belum di-provision |
| Production | https://api-merchant.kesles.com | DNS 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:
-
Terima shared Go package
psp_integration_govia 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/, tambahrequire+replacedirective digo.modtim Anda ke path lokal tersebut. Module IDgit.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.comadalah aspirational identifier — server git belum di-setup live. Kalau nanti Kesles deploy Gitea/Forgejo/GitLab CE digit.kesles.com, tinggal hapusreplacedirective →go get git.kesles.com/merchant/packages/psp_integration_go@latestlangsung work tanpa rename apapun.
- Vendor zip (rekomendasi sekarang) — tim Kesles kirim folder via 1Password Send / Signal DM, extract ke
-
Terima credentials via secure channel (1Password Send / Signal DM)
-
Set env vars:
KESLES_MERCHANT_KEY_ID,KESLES_MERCHANT_HMAC_SECRET,KESLES_MERCHANT_BASE_URL -
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.comKeyID: os.Getenv("KESLES_MERCHANT_KEY_ID"),Secret: os.Getenv("KESLES_MERCHANT_HMAC_SECRET"),}) -
Call endpoints:
// Saat bank callback masuk dengan NMIDm, err := client.GetMerchantByNMID(ctx, "ID102326873XXXX")// Forward transaction event_, err := client.ForwardTransactionEvent(ctx, envelope, eventID) -
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=1setelah backend Godashboard_apirunning 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=1dengan dummy credential untuk verify routing
- Local via VPN/port-forward:
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.