93. Payment Settlement Uniqueness and paid_late Package Effects
Status
Accepted for MVP. Closes TBC-PAY-SETTLEMENT-01 from PRD-GUIDELINE-REVIEW.md (Round 1 P1-10 and Round 2 R2 follow-up). Implements the at-most-one successful settlement per Booking/purchase intent invariant that the existing idempotency-by-provider-event-id (mentioned in ADR 0059 and CONTEXT.md:41) does not catch, and defines the paid_late package creation effects left open by ADR 0059 lines 13–20. Production launch still requires TBC-STACK-01 (Worker + D1 vs Supabase + Postgres) to be closed before any migration is executed; the SQL DDL in §6 is written for the Postgres family with the D1/SQLite equivalent in §7.
Ringkasan eksekutif (Bahasa Indonesia)
- At-most-one successful settlement per Booking/purchase intent. Sebuah
Booking(atauPackagePurchasepurchase intent) hanya boleh memiliki tepat satuPaymentdengansettled_at IS NOT NULLdanstatus = 'paid'. Invariant ini ditegakkan oleh unique partial index padapayment(booking_id) WHERE settled_at IS NOT NULL AND status = 'paid'(Postgres) atau unique partial index di D1/SQLite. DuaPaymentEventberbeda yang sama-sama statuscapture/settlementtidak boleh menghasilkan duaPaymentsettled. - Verifikasi amount/currency/order/merchant wajib, bukan hanya authenticity/signature.
PaymentEventyang verified oleh signature saja belum cukup; nilaigross_amount,currency,order_id(matchingBooking.id), danmerchant_id(matching configured Midtrans merchant) harus sama denganOfferSnapshotdanBooking.snapshotted_amount. Ketidakcocokan →PaymentEventdi-discard (logged sebagaimismatch), tidak menghasilkanPaymentbaru dan tidak menggerakkan Booking/Package state. - Idempotency key scope: lifetime, payload fingerprint. Idempotency record (
payment_event_idempotency) keyed by(provider_event_id, payment_intent_id)adalah lifetime (tidak pernah di-purge selamaPaymentEventmasih ada). Payload fingerprint (sha256(canonical_payload)) disimpan di side record: same-key/same-payload → return existing result; same-key/different-payload → typed failureidempotency_key_collision, tidak pernah diam-diam overwrite. - Out-of-order / repeated-status / reversal mapping. Adapter memetakan
capture/settlement(final success),pending(transient),deny/cancel/expire/failure(terminal failure),refund/chargeback/partial_refund(reversal — ignored atPaymentlevel karena refund adalahRefundActionterpisah), danchallenge/fraud(status investigasi, tidak menggerakkan state). Pengulangancaptureuntuk satu order yang sudah settled adalah no-op (Payment idempotent di levelsettled_at). - Crash window strategy. Tiga window berbeda ditangani eksplisit: (a) antara provider API call dan persistence (optimistic insert
Paymentstatus=pending+ idempotency record atomic), (b) antara verified webhook dan state transition (webhook handler transaksi: insert idempotency record → insertPaymentEvent→ apply state transition → emit outbox, semua dalam satu transaction), (c) antara state transition dan outbox delivery (transactional outbox pattern; outbox dispatcher best-effort retry dengan idempotency). paid_latepackage: Option A dipilih.PackagePurchase+ orderedSessionEntitlement+PackageValiditydibuat saat webhook verified (tepat ketikaPaymentdi-settle), dengan first session scheduled atau held mengikuti state booking asli. Jika slot asli tidak dapat direacquire (overlap),PackagePurchasetetap dibuat denganstatus = paid_latedan firstSessionEntitlementdi-setstate = pending_schedule;Appointmentasli dimarkcancelled_replaced_by_paid_lateatau tetappending_payment(existing). Admin resolution flow men-decide refund / reversal / client-approved alternative (lihat §5).
Context
CONTEXT.md:41 mengizinkan multiple payment attempts per Booking. ADR 0059 dan praktik idempotency-by-provider-event-id (IMPLEMENTATION-GUIDE.md §8.2) menjamin webhook replay aman, namun tidak mencegah dua Payment berbeda (misal dua checkout attempt ke Snap yang masing-masing membuat order_id berbeda, atau duplicate routing path) sama-sama sukses untuk Booking yang sama. Risiko: double-paid capture, double-issued PackagePurchase, double-confirmed Appointment, atau financial truth mismatch yang harus direkonsiliasi manual.
Untuk package, ADR 0059 line 13–18 sudah memutuskan paid_late/reconciliation path untuk single-session Booking, dan line 17 menyebutkan "reacquire original slot atomically jika masih free". Untuk package Booking, ADR 0059 tidak menjawab apakah PackagePurchase + ordered SessionEntitlement + PackageValidity dibuat pada saat webhook verified (Option A), ditunda sampai Admin resolution (Option B), atau dibuat dengan first session held sebagai pending_schedule (Option C — turunan dari A). Ticket #5 mengangkat keputusan ini dan meminta closure eksplisit.
Ticket #5 acceptance criteria: (1) integration test dua webhook sukses berbeda ID untuk Booking sama → hanya satu yang settle, yang lain masuk reconciliation; (2) integration test paid-late package → Admin resolution flow menciptakan PackagePurchase dengan first session scheduled atau held untuk Admin manual scheduling. ADR ini menjawab kedua acceptance criteria dan menambahkan uniqueness untuk amount/currency/order/merchant, idempotency collision handling, dan crash-window safety.
Diskusi multi-perspektif
Privacy (klinis/etis)
- Duplicate settlement tidak boleh bocor keluar sebagai "double-billed client"; hanyalah reconciliation record internal. Notifikasi konfirmasi pembayaran mengikuti existing rule: satu email konfirmasi per
Booking(atau perPackagePurchase), dikirim setelah uniqueness check passed. Tidak ada email tambahan untuk duplicate-payment-as-noop. - Late payment dan duplicate payment tidak boleh menunda
ConsentRecordactivation. Consent tetap mengikutiBooking/participant timeline, bukan payment timeline. Verified webhook menggerakkan state Booking/Appointment/PackagePurchase tanpa menunggu consent verifikasi ulang (consent sudah diverifikasi saat checkout). paid_latepackage yang dibuat denganfirst_session_entitlement.state = pending_scheduletidak terekspos ke klien sebelum Admin resolution; klien melihat "paket Anda sudah aktif, kami akan menghubungi untuk jadwal sesi pertama" (atau equivalent copy yang tidak menjanjikan slot spesifik). Klien tidak boleh melihat "kami akan re-acquire slot Anda".
Operations (admin/finance)
- Admin workspace perlu membedakan tiga status
paid_late:
1.paid_late_slot_reacquired(Booking single-session atau package first session) → original slot berhasil di-claim atomically, Appointment confirmed normal.
2.paid_late_slot_unavailable(Booking single-session) → tidak ada Appointment; Payment tetap paid; Admin resolusi via refund atau hold.
3.paid_late_first_session_pending(package) →PackagePurchasepaid,SessionEntitlement #1state = pending_schedule, Admin resolusi via scheduling alternative atau refund per kasus. - Duplicate-payment reconciliation record (
payment_reconciliation) membawa reasonduplicate_provider_event,payload_mismatch, atauidempotency_collisionagar Admin dapat review cepat. Tidak otomatis refund — Admin memutuskan setelah cek Midtrans dashboard dan Booking/Purchase context. - Financial reporting membaca
Payment.settled_at(bukanPaymentEvent.status); oleh karena itu uniqueness constraint padaPayment.settled_at IS NOT NULLadalah sumber kebenaran finansial.
Engineering (aggregate & schema)
- Atomicity boundary: verifikasi webhook, insert
Paymentsettled, insertPaymentEvent, state transition (Booking/Appointment/PackagePurchase/Entitlement), dan outbox event harus berada dalam satu application transaction. Crash window dipecah menjadi tiga dan ditangani di §4. - Payment vs PaymentEvent separation:
Paymentadalah current projection denganstatus,settled_at,booking_id,amount_cents,currency,provider,provider_payment_id;PaymentEventadalah append-only log denganprovider_event_id,event_type,payload_hash,verified_at,raw_payload(redacted). Duplicate event harus no-op insert (idempotency) atau insert newPaymentEventrow + no-op transition (jika sudah settled). - Idempotency record keyed by
(provider_event_id, payment_intent_id)danpayload_hash. Lifetime scope karenaPaymentEventjuga append-only. Tidak ada TTL expiry. - Crash window (a) — antara
createCheckoutAPI call dan persistence: optimistik insertPaymentstatus = 'pending'+ insertpayment_event_idempotencykeyed by(provider_event_id = null, intent_key = createCheckout_correlation_id)dalam satu transaction. Webhook datang kemudian, match byorder_id(Booking.id); idempotency check prevents duplicatePaymentinserts. - Crash window (b) — antara verified webhook dan state transition: webhook handler dalam satu transaction. Step: (1)
BEGIN; (2)INSERT INTO payment_event_idempotency ... ON CONFLICT DO NOTHING RETURNING id— jika conflict, return existing event result; (3)INSERT INTO payment_event ...; (4) verifikasi amount/currency/order/merchant againstOfferSnapshotdanBooking— jika mismatch,ROLLBACKdan logpayment_event_mismatch; (5) apply state transition (UPDATE payment SET status = 'paid', settled_at = ...jika baru; atau no-op jika sudah settled); (6) emit outbox event; (7)COMMIT. - Crash window (c) — antara transition dan outbox delivery: transactional outbox pattern. Outbox row inserted dalam transaction yang sama; dispatcher best-effort dengan idempotency (event_id unique). Retry dengan exponential backoff; failure menjadi
outbox_dead_lettersetelah N retry yang akan direview Admin (bukan silent success). - Option A vs B vs C untuk
paid_latepackage: lihat §5.2 — A dipilih karena (i) financial truth terjaga, (ii) Admin resolution dapat berupa reschedule/refund/alternative tanpa invent new lifecycle state, (iii) konsistensi denganADR 0059"no silent refund or silent slot substitution".
UX (checkout & Admin)
- Browser redirect tidak pernah menampilkan "pembayaran berhasil" yang final sebelum verified webhook. UI menampilkan "Verifying payment..." spinner sampai client polling endpoint mengembalikan status final (
paid,paid_late_*,failed). Duplicate webhook tidak menggandakan email konfirmasi. - Email konfirmasi untuk paid-late package menggunakan copy yang sama dengan paid-on-time package (satu template), tidak ada perbedaan copy yang membingungkan klien.
- Admin workspace untuk paid-late package menampilkan:
PackagePurchasepaid;SessionEntitlement #1statepending_scheduleatauscheduled(jika slot asli dapat direacquire);- remaining entitlements
state = availabledenganvalidity_start = now()(bukan dari original checkout); - opsi Admin: (a) schedule alternative slot for #1, (b) refund full via RefundAction, (c) hold for client decision via WhatsApp.
Decision
1. Invariant: at-most-one successful settlement per Booking/purchase intent
1.1 Pernyataan invariant
Untuk satu
Booking.id, terdapat paling banyak satuPaymentdenganstatus = 'paid'(yaitusettled_at IS NOT NULL). Untuk satuPackagePurchase.id, terdapat paling banyak satuPaymentdenganstatus = 'paid'(refleksi dari Booking-level uniqueness melalui FKBooking.id).
1.2 Mekanisme enforcement
- Unique partial index pada tabel
payment: - Postgres:
CREATE UNIQUE INDEX payment_one_settled_per_booking ON payment(booking_id) WHERE status = 'paid' AND settled_at IS NOT NULL; - D1/SQLite:
CREATE UNIQUE INDEX payment_one_settled_per_booking ON payment(booking_id) WHERE status = 'paid' AND settled_at IS NOT NULL;(SQLite supports partial unique index sejak 3.8.0). - Application-level precheck di webhook handler:
SELECT 1 FROM payment WHERE booking_id = ? AND status = 'paid' LIMIT 1di dalam transaction sebelum insert. Jika ditemukan, return existingPayment(idempotency). Precheck + DB constraint = same pattern sebagaiADR 0091capacity overlap. - Forbidden state: dua
Paymentrow denganbooking_id = Xdanstatus = 'paid'keduanya ada. DB constraint menolak insert kedua. Application precheck menolak sebelum insert. Tested dengan integration test acceptance criteria #1.
1.3 Status enum Payment
| Status | Settled_at | Meaning |
|---|---|---|
pending |
NULL | Payment created saat createCheckout, menunggu webhook |
paid |
NOT NULL | Verified capture/settlement event applied |
paid_late_slot_reacquired |
NOT NULL | Late verified success; original slot reacquired |
paid_late_slot_unavailable |
NOT NULL | Late verified success; slot tidak dapat direacquire; Admin resolution required |
paid_late_first_session_pending |
NOT NULL | Package late success; first entitlement held pending Admin scheduling |
failed |
NULL | Verified deny/cancel/expire/failure event applied |
refunded_full |
NOT NULL → di-update via RefundAction summary | full_refund RefundAction completed |
refunded_no_disbursement |
NOT NULL | no_refund RefundAction recorded as audited non-disbursement (Payment tetap paid secara financial, summary menampilkan refunded di Admin UI) |
Payment.status adalah derived current projection, tidak pernah di-overwrite historical — perubahan dari paid → refunded_full adalah update field summary (computed dari RefundAction aggregate), bukan rewrite history. PaymentEvent append-only; Payment.settled_at immutable setelah set.
2. Verifikasi amount / currency / order / merchant
2.1 Pernyataan invariant
Sebuah verified
PaymentEventhanya menghasilkanPaymentsettled jika dan hanya jikaevent.gross_amount == offer_snapshot.amount_cents AND event.currency == offer_snapshot.currency AND event.order_id == booking.id AND event.merchant_id == configured_merchant_id.
2.2 Mekanisme enforcement
- Adapter
verifyNotificationmengembalikanVerifiedPaymentEventsetelah signature verification + value match check. - Application handler melakukan second verification against
Booking.snapshotted_amount,OfferSnapshot.amount_cents,OfferSnapshot.currency, danBooking.id. Ini menjamin defense-in-depth: jika adapter bug atau compromised, value mismatch masih di-catch. - Mismatch →
INSERT INTO payment_event_mismatch_log (...)(append-only audit),INSERT INTO payment_event (event_type='mismatch_log', ...)(audit trail),ROLLBACKtransaction, tidak ada state transition. Midtrans dashboard dirujuk manual untuk cross-check. - Currency mismatch (
IDRvsUSD/other) → mismatch, karena launch hanya IDR.
2.3 Field verification table
| Field | Source of truth | Verification |
|---|---|---|
gross_amount |
OfferSnapshot.amount_cents (snapshotted at booking creation per ADR 0042) |
exact match (cents integer) |
currency |
OfferSnapshot.currency (default IDR) |
exact match |
order_id |
Booking.id (UUID string) |
exact match |
merchant_id |
Configured Midtrans merchant ID (env var) | exact match |
signature_key |
Computed from payload + server key | signature verification (existing) |
3. Idempotency key scope
3.1 Lifetime scope
Idempotency records (
payment_event_idempotency) keyed by(provider_event_id, payment_intent_id)adalah lifetime: tidak ada TTL atau purge selamaPaymentEventmasih ada. KarenaPaymentEventappend-only dan retained perTBC-PRIVACY-01audit/legal policy, idempotency record seumur hidup retensi PaymentEvent.
3.2 Same-key/different-payload behavior
- Same key + same payload hash → return existing event result (no-op transition, idempotency hit).
- Same key + different payload hash → typed failure
idempotency_key_collision, ROLLBACK transaction, logpayment_idempotency_collisionkepayment_event_mismatch_log. TIDAK diam-diam overwrite. Midtrans support contacted untuk investigate. - Different key + same payload → diperlakukan sebagai duplicate event terpisah; new
PaymentEventrow inserted; existingPaymentunaffected (no-op transition karena sudah settled, atau normal transition jika belum).
3.3 Payload fingerprint
payment_event_idempotency.payload_hash = sha256(canonical_json(payload))— canonical JSON serialization (sorted keys, no whitespace) untuk stabilitas.- Stored sebagai
bytea(Postgres) atauBLOB(D1/SQLite).
4. Out-of-order / repeated-status / reversal mapping
4.1 Status mapping table
| Provider status | PaymentEvent.event_type |
Effect on Payment |
Effect on Booking/Appointment/Package |
|---|---|---|---|
capture / settlement (final) |
capture |
jika belum settled: status = 'paid', settled_at = now() |
state transition normal |
pending (transient) |
pending |
no-op | no-op (Booking tetap pending_payment) |
deny |
deny |
status = 'failed' (jika belum terminal lain) |
Booking → failed |
cancel |
cancel |
status = 'failed' |
Booking → failed |
expire |
expire |
status = 'failed' |
Booking → expired, SlotHold released |
failure / failure_late |
failure |
status = 'failed' |
Booking → failed |
refund (full) |
refund |
no-op di Payment (refund adalah RefundAction terpisah) |
depends on RefundAction decision |
partial_refund |
partial_refund |
no-op di Payment (partial refund deferred; treat as full_refund atau no_refund action di RefundAction) |
depends on Admin decision |
chargeback |
chargeback |
no-op di Payment (financial reversal handled outside booking product) |
Admin alerted via reconciliation report |
challenge / fraud |
challenge |
no state change | Admin review; Booking tetap pending_payment |
recurring / subscription |
n/a | not applicable — launch tidak ada subscription | n/a |
4.2 Repeated capture / settlement
- Duplicate
captureuntukBookingyang sudah settled → idempotency hit, return existing Payment status. Tidak insertPaymentkedua. Email konfirmasi tambahan tidak dikirim (idempotency tracked vianotification_log). - Reverse order:
cancelsetelahcapture→canceldi-apply jikaPayment.statusbelumpaid(artinya admin/expire membatalkan mid-flight); jika sudahpaid,canceldi-ignore dan Admin alerted (karena settlement sudah final di Midtrans).
4.3 Reversal mapping
refundevent dari Midtrans tidak menggerakkanPaymentstate. Refund adalahRefundActionterpisah yang di-trigger oleh Admin (ADR 0077full/no-refund only). Webhookrefundevent hanya di-log untuk reconciliation.chargebackevent di-log kepayment_chargeback_log(append-only); Admin alerted; tidak mengubahPaymentstatus otomatis.
5. paid_late package creation
5.1 Option analysis
| Option | Deskripsi | Pro | Kontra |
|---|---|---|---|
| A | PackagePurchase + ordered SessionEntitlement + PackageValidity dibuat saat webhook verified; jika slot reacquire gagal, first SessionEntitlement.state = 'pending_schedule' |
Financial truth terjaga; Admin resolution tanpa invent new state; konsistensi dengan ADR 0059 |
Butuh pending_schedule state untuk first entitlement |
| B | Tunda sampai Admin resolution | Tidak ada state ambigu | Kehilangan financial truth (Payment paid tapi PackagePurchase belum ada → reporting jadi susah); Admin jadi bottleneck untuk hal yang seharusnya otomatis |
| C | Buat dengan first session held sebagai pending_schedule (variant A) |
Sama dengan A | Tidak ada perbedaan dengan A kecuali kalau diinterpretasikan sebagai "Admin must resolve before any entitlement visible" — tapi itu mereintroduce B |
Decision: Option A (eagle eye: identik dengan C, tapi C saya perjelas sebagai "Admin resolves ke real slot", sedangkan A adalah "first session held dengan pending_schedule atau scheduled jika reacquire berhasil, Admin resolves via existing reconciliation flow tanpa gate"). Rationale: financial truth, tidak ada new lifecycle state invented, Admin resolution flow sudah ada dari ADR 0059 line 18.
5.2 paid_late package atomic creation
Pada saat verified webhook untuk late payment (hold sudah expired):
Payment.status = 'paid_late_first_session_pending'(atau'paid_late_slot_reacquired'jika reacquire berhasil — lihat §5.3).PackagePurchasedibuat denganstatus = 'paid'(atau'paid_late'— konsisten dengan Payment status enum).PackageValiditydibuat denganvalidity_start = now()(bukan original checkout time karena hold sudah expired — slot pertama tidak valid dari waktu itu).- Ordered
SessionEntitlementrows:
-SessionEntitlement #1:state = 'pending_schedule'(jika reacquire gagal) atau'scheduled'+ linked ke Appointment reacquired (jika reacquire berhasil).
-SessionEntitlement #2..N:state = 'available'. - Transactional effects: Booking original di-mark
paid_late_reconciled(status terminal — bukanconfirmedkarena original slot tidak confirmed; bukanfailedkarena payment sukses). Booking adalah paid late, package adalah paid late, original slot released (jika reacquire gagal) atau tetap held (jika reacquire berhasil).
5.3 Slot reacquire attempt
Mengikuti ADR 0059 line 17, ADR 0091 capacity overlap detection:
- Di dalam transaction, query
CapacityReservationexisting untuk(psychologist_id, original_starts_at, original_ends_at, state IN {hold_active, confirmed})denganeffective_range overlapcheck. - Jika tidak ada overlap dan original
AvailabilitySlotmasihstate = 'available':
-INSERT INTO capacity_reservation (reservation_kind = 'confirmed', state = 'confirmed', ...)— atomic claim.
-INSERT INTO appointment (state = 'confirmed', ...)— linked ke original slot.
-SessionEntitlement #1.state = 'scheduled', linked ke Appointment ini.
-Payment.status = 'paid_late_slot_reacquired'.
-Booking.status = 'paid_late_slot_reacquired'. - Jika ada overlap atau slot unavailable:
- Tidak insertCapacityReservation.
- Tidak insertAppointment.
-SessionEntitlement #1.state = 'pending_schedule'.
-Payment.status = 'paid_late_first_session_pending'.
-Booking.status = 'paid_late_slot_unavailable'.
- OriginalBooking.snapshotted_slot_idreference di-keep untuk audit (Admin perlu tahu slot mana yang awalnya dimaksud).
5.4 Admin resolution flow untuk paid_late_first_session_pending
Admin workspace melihat PackagePurchase dengan flag requires_first_session_scheduling = true. Resolution options (existing Admin WhatsApp flow per ADR 0067):
- Option X: Schedule alternative slot for
SessionEntitlement #1→ScheduleNextEntitlementdengan slot baru (psikolog + klien agreed);stateberubah daripending_schedulekescheduled. - Option Y: Full refund via
RefundAction.status = 'full_refund'→PackagePurchase.status = 'closed_refunded', semua remaining entitlementsstate = 'cancelled'(tidak consumed karena tidak dipakai),Payment.status = 'refunded_full'. - Option Z: Hold for client decision →
PackagePurchase.requires_first_session_schedulingtetap true; Admin follow-up via WhatsApp. Tidak ada timeline otomatis; Admin decides based on client response.
Admin tidak boleh otomatis melakukan refund tanpa eksplisit client/Admin decision (consistent dengan ADR 0076 no auto-cutoff).
6. SQL migration (Postgres family)
-- Migration 0093: payment settlement uniqueness and paid_late package effects
-- 6.1 Unique partial index: at-most-one successful settlement per Booking
CREATE UNIQUE INDEX IF NOT EXISTS payment_one_settled_per_booking
ON payment(booking_id)
WHERE status = 'paid' AND settled_at IS NOT NULL;
-- 6.2 Idempotency record (lifetime, keyed by provider event id)
CREATE TABLE IF NOT EXISTS payment_event_idempotency (
payment_intent_id uuid NOT NULL,
provider_event_id text NOT NULL,
payload_hash bytea NOT NULL,
payment_event_id uuid NOT NULL REFERENCES payment_event(id),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (provider_event_id, payment_intent_id)
);
-- 6.3 Payment status enum extension (additive, idempotent)
ALTER TYPE payment_status ADD VALUE IF NOT EXISTS 'paid_late_slot_reacquired';
ALTER TYPE payment_status ADD VALUE IF NOT EXISTS 'paid_late_slot_unavailable';
ALTER TYPE payment_status ADD VALUE IF NOT EXISTS 'paid_late_first_session_pending';
ALTER TYPE payment_status ADD VALUE IF NOT EXISTS 'refunded_no_disbursement';
-- 6.4 SessionEntitlement state enum extension (additive)
ALTER TYPE session_entitlement_state ADD VALUE IF NOT EXISTS 'pending_schedule';
-- 6.5 PackagePurchase: requires_first_session_scheduling flag
ALTER TABLE package_purchase
ADD COLUMN IF NOT EXISTS requires_first_session_scheduling boolean NOT NULL DEFAULT false;
-- 6.6 Payment mismatch log (append-only audit)
CREATE TABLE IF NOT EXISTS payment_event_mismatch_log (
id uuid PRIMARY KEY,
booking_id uuid NOT NULL,
payment_intent_id uuid,
provider_event_id text,
mismatch_kind enum NOT NULL, -- 'amount' | 'currency' | 'order_id' | 'merchant_id' | 'idempotency_collision' | 'other'
expected_value jsonb,
actual_value jsonb,
payload_hash bytea,
detected_at timestamptz NOT NULL DEFAULT now(),
resolved_at timestamptz,
resolved_by uuid, -- staff_id
resolution_action text
);
CREATE INDEX IF NOT EXISTS payment_mismatch_unresolved
ON payment_event_mismatch_log(booking_id)
WHERE resolved_at IS NULL;
-- 6.7 Outbox table (transactional outbox for state transition events)
CREATE TABLE IF NOT EXISTS application_outbox (
id uuid PRIMARY KEY,
aggregate_type text NOT NULL, -- 'payment' | 'booking' | 'package_purchase' | etc
aggregate_id uuid NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
dispatched_at timestamptz,
dispatch_attempts int NOT NULL DEFAULT 0,
last_error text
);
CREATE INDEX IF NOT EXISTS outbox_pending
ON application_outbox(created_at)
WHERE dispatched_at IS NULL;
-- 6.8 Booking status enum extension (additive)
ALTER TYPE booking_status ADD VALUE IF NOT EXISTS 'paid_late_slot_reacquired';
ALTER TYPE booking_status ADD VALUE IF NOT EXISTS 'paid_late_slot_unavailable';
ALTER TYPE booking_status ADD VALUE IF NOT EXISTS 'paid_late_reconciled';
-- 6.9 PackagePurchase status enum extension (additive)
ALTER TYPE package_purchase_status ADD VALUE IF NOT EXISTS 'paid_late';
ALTER TYPE package_purchase_status ADD VALUE IF NOT EXISTS 'closed_refunded';
Rollback (Postgres): drop indexes, drop tables (idempotency, mismatch log, outbox), drop columns, drop enum values (tidak reversible di Postgres; alternative: leave enum values unused, document sebagai deprecated).
7. D1 / SQLite equivalent
D1 (SQLite) tidak mendukung ALTER TYPE ... ADD VALUE. Solusi: enum values didefinisikan sebagai application-level CHECK constraint atau TEXT dengan application-level validation. ADD COLUMN IF NOT EXISTS supported sejak SQLite 3.35.0. Partial unique index supported sejak 3.8.0.
-- 7.1 Unique partial index (sama syntax, supported)
CREATE UNIQUE INDEX IF NOT EXISTS payment_one_settled_per_booking
ON payment(booking_id)
WHERE status = 'paid' AND settled_at IS NOT NULL;
-- 7.2 Idempotency record (sama DDL)
CREATE TABLE IF NOT EXISTS payment_event_idempotency (
payment_intent_id text NOT NULL,
provider_event_id text NOT NULL,
payload_hash blob NOT NULL,
payment_event_id text NOT NULL REFERENCES payment_event(id),
created_at text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
PRIMARY KEY (provider_event_id, payment_intent_id)
);
-- 7.3 SessionEntitlement state CHECK constraint (SQLite tidak punya enum ALTER)
-- Pre-existing CHECK constraint di session_entitlement harus sudah include 'pending_schedule'.
-- Jika belum, ALTER TABLE recreate:
-- ALTER TABLE session_entitlement RENAME TO session_entitlement_old;
-- CREATE TABLE session_entitlement (... new CHECK ...);
-- INSERT INTO session_entitlement SELECT * FROM session_entitlement_old;
-- DROP TABLE session_entitlement_old;
-- 7.4 PackagePurchase column
ALTER TABLE package_purchase
ADD COLUMN requires_first_session_scheduling integer NOT NULL DEFAULT 0;
-- (0 = false, 1 = true; SQLite tidak punya boolean native)
-- 7.5 Mismatch log (sama DDL dengan text/timestamp adjustments)
CREATE TABLE IF NOT EXISTS payment_event_mismatch_log (
id text PRIMARY KEY,
booking_id text NOT NULL,
payment_intent_id text,
provider_event_id text,
mismatch_kind text NOT NULL CHECK (mismatch_kind IN
('amount','currency','order_id','merchant_id','idempotency_collision','other')),
expected_value text, -- JSON serialized
actual_value text,
payload_hash blob,
detected_at text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
resolved_at text,
resolved_by text,
resolution_action text
);
CREATE INDEX IF NOT EXISTS payment_mismatch_unresolved
ON payment_event_mismatch_log(booking_id)
WHERE resolved_at IS NULL;
-- 7.6 Outbox (sama DDL)
CREATE TABLE IF NOT EXISTS application_outbox (
id text PRIMARY KEY,
aggregate_type text NOT NULL,
aggregate_id text NOT NULL,
event_type text NOT NULL,
payload text NOT NULL,
created_at text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
dispatched_at text,
dispatch_attempts integer NOT NULL DEFAULT 0,
last_error text
);
CREATE INDEX IF NOT EXISTS outbox_pending
ON application_outbox(created_at)
WHERE dispatched_at IS NULL;
8. Konsekuensi
Positif:
- Financial truth terjaga — at-most-one
Paymentsettled per Booking, dengan amount/currency/order/merchant match. - Duplicate webhook aman — idempotency hit returns existing result, no-op transition.
- Late payment untuk package tidak kehilangan PackagePurchase —
paid_latepath membuat PackagePurchase dengan first entitlementpending_schedule, Admin dapat resolve. - Crash window eksplisit ditangani di tiga boundaries, dengan transactional outbox untuk delivery guarantee.
- Konsisten dengan
ADR 0059(late payment reconciliation),ADR 0091(capacity overlap),ADR 0023-style idempotency primitives.
Biaya dan constraint:
- Tiga window berbeda menambah kompleksitas operational (Admin perlu membaca runbook).
- Unique partial index harus di-maintain di migration (rollback strategy: drop index, bukan drop data).
- Outbox dispatcher butuh monitoring;
outbox_pendingindex membantu query untuk dead-letter detection. paid_late_first_session_pendingadalah new state yang Admin perlu pahami di workspace (admin UX impact).payment_event_mismatch_logdan reconciliation entries butuh Admin review cadence.
Forbidden:
- Multiple
Paymentrows settled perBooking.id. - Verifikasi signature saja tanpa amount/currency/order/merchant match.
- Idempotency record dengan TTL.
- Silent refund atau silent slot substitution setelah late payment.
- Outbox dispatcher yang menulis langsung ke external system tanpa idempotency.
- Payment status rewrite in-place untuk merepresentasikan perubahan historis (mengikuti
IMPLEMENTATION-GUIDE.md §8.1append-only rule).
Open follow-up
- Outbox dispatcher implementation: harus didefinisikan sebagai background worker dengan retry policy (exponential backoff, max attempts, dead-letter routing). Belum ada implementation ADR; in-scope untuk Slice 7 atau Slice 8 (notifications).
- Reconciliation cadence: kapan Admin review
payment_event_mismatch_log(unresolved entries)? Real-time alert via notification, atau daily digest? Tergantung operational policy. requires_first_session_schedulingUX di Admin workspace: layout dan CTA. Belum ada wireframe; in-scope untuk Slice 6 cancellation/refund atau Slice 5 staff/ClientAccess.paid_late_first_session_pendingautomatic expiry: jika Admin tidak resolve dalam N hari, apakah PackagePurchase auto-close atau tetap hold? Konsisten denganADR 0076no auto-cutoff — rekomendasi: tidak ada auto-expiry; Admin decides.- Chargeback handling: event
chargebackdi-log tapi tidak menggerakkan state; apakah perlu automatic RefundAction atau Admin-decide? Konsisten denganADR 0077full/no-refund Admin-decide — rekomendasi: Admin-decide. - Cross-aggregate transaction strategy:
Booking/Payment/PackagePurchase/SessionEntitlementadalah aggregate berbeda; ADR ini mengasumsikan satu application transaction menyentuh semuanya (Postgres allows; D1 serial per DB). Pada Postgres production dengan cross-aggregate writes, pertimbangkan transactional outbox + saga atau 2PC. Belum ada ADR.
Reference
ADR 0023(idempotency primitives) — referenced untuk pattern, content tidak tersedia saat penulisan ADR iniADR 0059-late-payment-reconciliation.md— late payment untuk single-session BookingADR 0076-case-by-case-cancellation.md— no auto-cutoffADR 0077-launch-full-or-no-refund.md— refund vocabularyADR 0042-offer-snapshot-immutable.md— OfferSnapshot sebagai source of truth untuk amount/currencyADR 0090-couple-participant-model.md— couple package participant (untuk paid-late couple handling)ADR 0091-capacity-overlap-buffer.md— capacity reservation overlap detectionIMPLEMENTATION-GUIDE.md §7 Payment implementation— patched alongside this ADRIMPLEMENTATION-GUIDE.md §8 Persistence and data rules— append-only, concurrency, idempotency keyDOMAIN-MODEL.mdPayment and refund section — patched alongside this ADRPRD-GUIDELINE-REVIEW.mdRound 1 P1-10 + Round 2 follow-up — TBC-PAY-SETTLEMENT-01 closed by this ADRCONTEXT.md:41— multiple payment attempts context- Ticket #5 — Settlement uniqueness & paid-late package (closes this ticket)