| name | awcms-mini-payment-gateway |
| description | Kerjakan bagian mana pun dari modul payment_gateway AWCMS-Mini (Issue |
AWCMS-Mini — Payment Gateway Module
payment_gateway (src/modules/payment-gateway, Issue #877, epic #868 SaaS
control plane Wave 1, ADR-0022) adalah modul control-plane KEENAM &
TERAKHIR — Official Optional Business Foundation, opt-in per tenant,
default-disabled, tenant-scoped. Ia menyediakan kapabilitas pembayaran
provider-netral: hosted checkout/session, webhook masuk bertandatangan,
event pembayaran ternormalisasi, refund, retry/DLQ, provider health + circuit
breaker, dan reconciliation. Ia BUKAN general ledger / AR-AP / double-entry
accounting / merchant settlement / tax engine, dan tidak pernah menyimpan
kredensial kartu / PAN (ADR-0022 §11).
Boundary keamanan KRITIS (patuhi semuanya)
- Status pembayaran TAK PERNAH dari redirect browser. Hanya webhook signed
tervalidasi ATAU hasil reconciliation yang mengubah status intent. Route
webhook/[providerAccountId].ts diautentikasi oleh id akun opaque (bind ke
TEPAT SATU tenant) + verifikasi adapter — bukan sesi JWT tenant.
- Panggilan provider SELALU di luar transaksi DB (ADR-0006).
initiateCheckout/
requestRefund commit intent + baris outbox DULU; worker
outbox-dispatch memanggil provider tanpa transaksi terbuka, lalu
finalize di transaksi terpisah. Outage provider → retry/backoff + circuit
breaker + DLQ, TAK PERNAH menahan/rollback transaksi sumber.
- Webhook inbox fail-closed WAJIB (
domain/webhook-security.ts +
webhook-intake.ts): (a) HMAC signature timing-safe; (b) timestamp/freshness
window ≤300s; (c) binding provider/account (payload account_ref ==
akun terikat → cegah cross-tenant substitution); (d) payload size; (e)
anti-replay via event-id PERSISTEN di DB (unique
(tenant, account, provider_event_id) — BUKAN in-memory); (f) ordering
(provider_sequence monotonic). Valid signed webhook update payment TEPAT
SEKALI (loser ON CONFLICT = no-op). Gagal/oversized/duplicate/out-of-order/
stale = tolak fail-closed + audit + reconciliation evidence.
- SSRF/open-redirect (
domain/endpoint-allowlist.ts): endpoint provider +
callback URL allow-listed per akun; validasi host via new URL() +
host equality (BUKAN startsWith prefix — memory
[[linkedin-media-trust-helper-862]]).
- Secret provider HANYA di
process.env — row akun menyimpan POINTER
env:VAR_NAME (domain/secret-ref.ts), TAK PERNAH nilai secret. Tidak di
tabel/event/log/audit. Rotasi = env deployment.
- Webhook envelope PII di-mask doc 04 SEBELUM persist (
domain/masking.ts)
— hanya snippet ter-mask (bukan raw body), referensi provider ter-MASK,
safe error class (tak pernah pesan provider mentah).
6 pola WAJIB (control-plane)
- default-disabled
defaultTenantState:"disabled" + gate di
tests/unit/module-governance-default-disabled.test.ts.
- boundary registry-wide (
module-boundary.test.ts): no reverse dep,
awcms_mini_payment_gateway_* hanya modul sendiri, port payment_outcome
neutral. Konsumsi billing #876 (billing_document_state read + recordPayment
Allocation write) HANYA di composition-root (_support.ts/scripts), jangan
di app/domain payment_gateway.
- concurrency SEMUA write path + jobs: row-lock
FOR UPDATE + UPDATE
ber-predikat status → 409, ON CONFLICT partial-unique,
replayConcurrentIdempotentWinner. Webhook EXACTLY-ONCE (event-id unique).
Job (dispatch/reconcile/expire) pakai LEASE per-(tenant,job_kind).
JANGAN Promise.all atas satu tx.
- immutability trigger DB: webhook inbox / normalized events / processing
attempts / reconciliations append-only; intent state machine forward-legal
(initiated→pending→{settled,failed,expired}; failed→initiated; settled→
{refunded,disputed}); refund result write-once. REVOKE DELETE.
Append-only = TIDAK ADA edit in-place, BUKAN "simpan selamanya". Trigger
keempat tabel itu
BEFORE UPDATE saja sejak 102 (#932). Sebelumnya
BEFORE UPDATE OR DELETE + raise tanpa syarat = TIDAK ADA role yang bisa
hapus, termasuk pemilik tabel → retensi mustahil, legal hold tak punya
objek. Batas DELETE sekarang ada di GRANT (awcms_mini_app tidak pernah,
awcms_mini_worker ya), pola usage_metering 087. Kalau menambah tabel
bukti append-only baru: pakai BEFORE UPDATE + grant, JANGAN sertakan
OR DELETE.
- no hash tenant-facing oracle; uang EXACT bigint minor-unit no-float
(
domain/money.ts).
- fail-closed tri-state SEMUA field parser (
request-parsing/request- validation). Event versioned emit dgn KONSTANTA ter-import LANGSUNG dari
event-type-registry, snapshot same-commit.
Provider adapter (opsional)
Adapter provider = External Integration off-by-default. Base HANYA ship
sandbox-adapter.ts (fake, untuk test + docs) di infrastructure/adapter- registry.ts. Provider nyata (Midtrans/Xendit/Stripe) opt-in via composition-root
repo ini lewat registerPaymentProviderAdapter, TAK PERNAH hardcoded di base.
LAN/offline (modul disabled) = 100% tanpa provider.
Kontrak & seam
-
CONSUMES billing_document_state (#876, read-only) untuk validasi invoice payable.
-
PROVIDES payment_outcome (_shared/ports/payment-outcome-port.ts) —
settled/refunded diteruskan ke subscription_billing.recordPaymentAllocation
(write path #876 sendiri, idempoten, audited), wired di composition-root.
-
Migrasi 093 (schema) + 094 (permissions) + 102 (retensi bukti, #932);
jobs payment-gateway:dispatch-outbox|reconcile|expire-sweep|purge.
-
dataLifecycle: 4 descriptor (domain/lifecycle-keys.ts) mode delegated,
jalur hapus TUNGGAL application/retention-purge.ts. Rantai
webhook_inbox <- normalized_events <- processing_attempts di-purge
FK-safe (daun dulu; tiap tingkat HANYA hapus baris tanpa anak yang masih
hidup — umur saja tidak aman, induk selalu lebih tua dari anaknya). Legal
hold di satu mata rantai memblokir SELURUH rantai (fail-closed);
reconciliations independen, di-hold terpisah. Test batas grant WAJIB
assert SQLSTATE 42501 — "sekadar throw" juga dipenuhi 23503 dari FK
anak dan lolos walau grant salah lebar.
-
Sinyal fleet-wide (#930): application/control-plane-signals.ts —
deadLetterDepth (outbox status='dead') + webhookBacklog (inbox
status='received'), dibaca bun run control-plane:fleet-sweep. DLQ BUKAN
backlog: tiap barisnya kerja provider yang sudah habis retry dan tak akan
pernah dicoba lagi tanpa aksi operator — itu kehilangan permanen, bukan
antrean yang terkuras sendiri.
Lihat src/modules/payment-gateway/README.md untuk detail ERD/tabel/state
machine.