Backend - payment_sessions → payment_requests rename across DB schema + 29 files - payment.service.js becomes product-agnostic owner: EventEmitter + Xendit wrapper + requestPayment / confirmPayment public API; legacy aliases retained for existing chat callers - Webhook handler at POST /api/shared/payment/webhooks/xendit, with constant-time token verification (8 vitest cases) - Server-driven pairing: payment.service emits payment_request.confirmed → pairing subscriber starts the blast. Legacy POST /chat/request still works during the cutover. - Reconciliation sweeper extended (re-emits events for confirmed rows with no chat session) - SIGTERM drain + startup reconciliation pass in server.js Customer app - waiting_payment_screen opens xendit_invoice_url via LaunchMode.inAppBrowserView - searching / no-bestie / targeted-waiting / pairing-notifier updated to consume the new payment_request_id contract - pending_payments_provider + bestie-unavailable dialog migrated Dev / testing - XENDIT_ENABLED=false is the safe default; .env.example documents the four new vars - backend/.dev/xendit-fake-webhook.sh exercises the handler without ngrok - 90/92 backend tests pass (two pre-existing session-timer flakes, unrelated); client_app analyzer clean - requirement/phase5-xendit-plan.md is the canonical reference Stage 8 (live E2E) blocked on Xendit test-mode keys. The dashboard's single-webhook-URL constraint will be worked around via a self-poll script next session. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
58 KiB
Phase 5 — Xendit Integration Plan
Goal: Replace the mocked payment confirmation (
/internal/_test/force-confirm-payment) with real Xendit Invoice creation + webhook-driven confirmation. Phase 4 built the payment session lifecycle (pending → confirmed → consumed) and the customer-facing payment shell screens. Phase 5 plugs Xendit in front ofpending → confirmed.Affects: backend, client_app, control_center (minor).
mitra_appis untouched — mitras never pay.See phase4-customer-flow-plan.md for the payment-shell work this builds on, and root
CLAUDE.mdfor project context.
Architectural Decisions (locked)
| # | Decision | Rationale |
|---|---|---|
| D1 | Use Xendit Invoice product (hosted page) | One product covers all payment methods; ~1 day to ship vs. ~1 week for per-method PG APIs. |
| D2 | Webhook is the only caller of confirmPayment |
Required for VA (async, customer pays hours later). App polls GET /payment-requests/:id. |
| D3 | App opens the Xendit checkout URL in an in-app browser (Chrome Custom Tab on Android, SFSafariViewController on iOS) via url_launcher LaunchMode.inAppBrowserView. WebView is explicitly out — no autofill, no SMS-OTP integration, anti-fraud risk, and PCI-discouraged. |
Custom Tab / SFVC is a real browser process so we keep the PCI separation + cookie/autofill/password-manager benefits, while the customer stays in the app's context (no jarring app-switch). Fallback to system browser is automatic when Custom Tab is unavailable. App stays on the waiting-payment screen and polls regardless. |
| D4 | external_id = our payment_requests.id UUID |
One-shot lookup in webhook handler; Xendit's uniqueness check doubles as idempotency. |
| D5 | Invoice invoice_duration mirrors payment_request_timeout_minutes |
Both sides expire together — no "Xendit confirms a payment we already marked expired". |
| D6 | Env flag XENDIT_ENABLED gates the integration |
false in dev (skip invoice creation, keep stub flow). true in staging/prod. Maestro never hits Xendit. |
| D7 | Keep /internal/_test/force-confirm-payment forever |
Maestro / manual QA need to advance past payment without Xendit. Internal listener only. |
| D8 | Webhook URL is dashboard-set per environment (test/live) | Xendit Invoice API does not accept per-request callback URL. Dev tunnel → test dashboard; prod URL → live dashboard. |
| D9 | App-facing POST /payment-requests/:id/confirm is gated by XENDIT_ENABLED |
When Xendit is on, only the webhook can confirm. When off, app can self-confirm (current dev/Maestro behavior). |
Architecture (revised 2026-05-23) — event-driven, product-agnostic payment service
Supersedes the lifecycle/coupling shape in Stages 1–7 below. Those stages still describe the original chat-only, webhook-as-confirmer design and will need rewriting once the open threads at the bottom of this section settle. The locked decisions D1–D9 above still hold — this section refines how the service is shaped internally, not what payment provider we use.
Why this shape
Future products (courses, merch, subscriptions) will need to be paid for the
same way chat sessions are. Rather than ship Phase 5 tightly coupled to chat
and refactor later, we shape the payment layer today as if it were already a
standalone microservice. Today it lives in-process as payment.service.js +
in-process EventEmitter; tomorrow the same API surface and event names work
unchanged across HTTP / pub-sub / network boundaries.
Renames vs. the original plan
| Original | Revised |
|---|---|
payment_requests table |
payment_requests |
*.payment_request_id FK columns |
*.payment_request_id |
PaymentRequestStatus constant |
PaymentRequestStatus |
requestPayment, confirmPayment, ... |
requestPayment, confirmPayment, expirePayment, ... |
/api/client/payment-requests/... routes |
/api/client/payment-requests/... |
/api/shared/payment/webhooks/xendit |
/api/shared/payment/webhooks/xendit (Xendit is payment-internal) |
payment_request_timeout_minutes config |
payment_request_timeout_minutes |
failed_delivery status (chat-specific) |
failed_delivery (product-agnostic) |
Schema additions for product-agnosticism
ALTER TABLE payment_requests
ADD COLUMN product_type TEXT NOT NULL DEFAULT 'chat_session',
ADD COLUMN product_metadata JSONB NOT NULL DEFAULT '{}'::jsonb;
product_type— tag ('chat_session', future'course_enrollment','merch_order', ...)product_metadata— opaque blob the payment service never opens. Chat stamps{duration_minutes, mode, targeted_mitra_id, is_extension}at create time and reads it back whenpayment_request.confirmedfires. Each future product stamps whatever shape it needs.
Public API surface (the future microservice contract)
What's not in this list is private to the payment service. Today these are function exports; the day we extract, each becomes an HTTP route or topic — caller code does not change.
requestPayment({ productType, productMetadata, customerId, amount, ttlMinutes }) → paymentRequest
getPayment(id) → paymentRequest
cancelPayment(id, customerId) → paymentRequest
markDeliveryFailed(id, causeTag) → paymentRequest // product-side reports inability to deliver
on(event, handler)
events: 'payment_request.confirmed' | 'payment_request.expired' | 'payment_request.cancelled' | 'payment_request.delivery_failed'
confirmPayment, expirePayment, consumePayment are internal to the
service. They are triggered by webhooks / sweepers / product code, not
called from arbitrary callers.
Trigger sources, service boundary, events
Events fire from inside the payment service when a row transitions to a terminal state. The Xendit webhook is one of several triggers; downstream subscribers don't care which fired the transition.
flowchart LR
subgraph Triggers["Triggers (cause state transitions)"]
XPaid[Xendit PAID webhook]
XExp[Xendit EXPIRED webhook]
Sweep[Background sweeper<br/>pending past expires_at]
Stub[Dev force-confirm stub<br/>/internal/_test/force-confirm-payment]
Cancel[Customer cancel button]
DelFail[Product service reports<br/>delivery failed<br/>pairing: no mitra · merch: oos · ...]
end
PS{{Payment Service<br/>requestPayment · confirmPayment ·<br/>expirePayment · cancelPayment ·<br/>markDeliveryFailed · consumePayment}}
subgraph Events["Events (emitted on terminal transitions)"]
Conf((payment_request.confirmed))
Exp((payment_request.expired))
Canc((payment_request.cancelled))
DelF((payment_request.delivery_failed))
end
subgraph Subs["Subscribers"]
Pair[Pairing service<br/>filter productType=chat_session<br/>read productMetadata<br/>start mitra blast]
Ops[Operator workflow<br/>refund / re-credit queue]
Future[Future product services<br/>course, merch, subscription]
end
XPaid --> PS
Stub --> PS
XExp --> PS
Sweep --> PS
Cancel --> PS
DelFail --> PS
PS --> Conf
PS --> Exp
PS --> Canc
PS --> DelF
Conf --> Pair
Conf --> Future
DelF --> Ops
Exp --> Ops
Lifecycle of a payment_request
stateDiagram-v2
[*] --> pending: requestPayment()<br/>row inserted · Xendit invoice created<br/>(no event — caller has the id)
pending --> confirmed: confirmPayment()<br/>emits payment_request.confirmed
pending --> expired: expirePayment()<br/>emits payment_request.expired
pending --> cancelled: cancelPayment()<br/>emits payment_request.cancelled
confirmed --> consumed: consumePayment()<br/>(product code on successful delivery)<br/>(no event — terminal success)
confirmed --> failed_delivery: markDeliveryFailed()<br/>emits payment_request.delivery_failed
consumed --> [*]
expired --> [*]
cancelled --> [*]
failed_delivery --> [*]
Notes on the lifecycle:
- No event at
pendinginsertion. The caller ofrequestPaymentalready has the row id; nobody else needs to be told. Events exist for unsolicited notifications across the service boundary. - No event on
confirmed → consumed. Consumption is the product code saying "I delivered the thing." It's an internal acknowledgement, not a state others need to react to. (Analytics/ledger can derive this from the row.) payment_request.expiredfires the same way whether the trigger was Xendit's EXPIRED webhook or our background sweeper. Subscribers see one event; the trigger source is invisible to them.failed_deliveryis the renamedfailed_delivery. Generalized so merch can use it for out-of-stock, courses for enrollment-full, etc. This is the event that drives the operator refund workflow.payment_request.failed(gateway-rejected) is intentionally absent. Xendit Invoice for VA/QRIS/e-wallet doesn't fail — it either gets paid or expires. We can add it later if/when a payment method that can decline (e.g. cards) is enabled.
Where Xendit lives in this picture
Xendit is a payment-internal implementation detail. Only payment.service.js
imports xendit-node. The webhook route lives under
/api/shared/payment/webhooks/xendit so the URL itself signals that the
provider is hidden behind the payment service.
Swapping a product to a different provider (Midtrans, DOKU) tomorrow is a
one-file change: add midtrans.adapter.js, route product-specific
requestPayment calls to it. The event names, subscriber code, and DB
schema are unchanged.
Event durability — reconciliation pattern (not outbox)
The problem. In-process EventEmitter is fire-and-forget with no
persistence. If the Node process dies between emit('payment_request.confirmed')
and the pairing handler completing, the event is lost. Same is true of
valkey pub/sub (the broker forwards messages in memory; nothing is
persisted, AOF doesn't help because pub/sub messages aren't stored in the
keyspace). Naively, this means a stuck customer: payment row says
confirmed but nothing started pairing.
The decision. Don't try to make events survive — make the system survive without depending on events surviving. Treat the DB row as the truth; treat events as performance optimizations that trigger work immediately when things go right. When things go wrong, re-derive what should have happened from the DB state via a reconciliation sweeper.
Four pieces:
-
EventEmitter as the trigger. After
confirmPayment()commits the DB transaction, firepayment_request.confirmed. Subscribers (pairing forchat_session, future product services for their own types) react immediately on the happy path. -
Subscribers are idempotent. Every handler starts by checking "is the work this event represents already done?" before doing anything. For the pairing subscriber:
payment.on('payment_request.confirmed', async ({ paymentId, productType }) => { if (productType !== 'chat_session') return const existing = await db.query( 'SELECT id FROM chat_sessions WHERE payment_request_id = $1', [paymentId]) if (existing.length) return // already pairing or paired, skip await startPairing(paymentId) })This makes re-running the handler safe — which is what reconciliation relies on.
-
Reconciliation sweeper extends the existing payment expiry sweeper (which already runs every minute in
server.js). New query, per product:SELECT id, product_type, product_metadata FROM payment_requests WHERE status = 'confirmed' AND confirmed_at < NOW() - INTERVAL '30 seconds' AND product_type = 'chat_session' AND NOT EXISTS (SELECT 1 FROM chat_sessions cs WHERE cs.payment_request_id = payment_requests.id) AND NOT EXISTS (SELECT 1 FROM pairing_failures pf WHERE pf.payment_request_id = payment_requests.id)For each row returned, call the same handler the lost event would have triggered. The 30-second buffer avoids racing with happy-path subscribers that are mid-flight.
-
Run reconciliation immediately at startup (not just on the minute tick) — catches anything that fell through the cracks during the restart window. Plus trap SIGTERM to drain in-flight handlers within Cloud Run's grace period:
process.on('SIGTERM', async () => { await app.close() // stop accepting new requests await new Promise(r => setTimeout(r, 8_000)) // let handlers drain process.exit(0) })
What this gives us:
| Scenario | Behavior |
|---|---|
| Happy path | Event fires immediately; pairing starts in ms. Same UX as if EventEmitter were durable. |
| Process dies mid-emit | Event lost; row state intact. Next sweeper tick (≤60s later) re-triggers. Customer waits up to ~1 min longer than normal but doesn't get stuck. |
| Process restart | Startup reconciliation runs immediately; catches everything missed during the restart window. |
| Subscriber crash mid-handler | DB shows "confirmed but no chat session yet"; sweeper re-triggers; subscriber's idempotency check resumes correctly. |
| Multi-instance Cloud Run | Each webhook routed to one instance; subscriber runs there. No cross-instance coordination needed because work itself is idempotent. |
What this gives up vs. the outbox pattern:
- Worst-case latency on event loss is ~60s (sweeper tick interval). Outbox dispatches in seconds.
- Only works for events with a corresponding DB row state. Every event we've designed (
payment_request.confirmed,.expired,.cancelled,.delivery_failed) qualifies because each maps to apayment_requests.statusvalue. An event that doesn't persist to a row state wouldn't be recoverable this way.
When to graduate to outbox (a future migration, not a Phase 5 concern):
- We add subscribers where >30s latency on the worst case is unacceptable (real-time fraud detection, etc.)
- We have multiple subscribers per event where per-subscriber acknowledgement matters (analytics OK to lose, pairing not)
- We extract payment service from the monolith — at which point a persistent queue between services is natural anyway
Cost of this approach: ~80 lines total. Zero new infrastructure, zero new dependencies, zero new tables. Re-uses the sweeper pattern already in server.js.
Locked decisions (2026-05-23 discussion)
- Event naming: resource-based —
payment_request.confirmed,payment_request.expired,payment_request.cancelled,payment_request.delivery_failed,payment_request.failed. Diagrams and API surface above reflect this. - Universal lifecycle: lock the current
pending → confirmed → consumed | expired | cancelled | failed | failed_deliveryfor all products for now. Revisit when a product emerges that genuinely needs additional terminal states (e.g.shippedfor physical goods). Until then, every product reuses the same enum. - Xendit product: stay on Invoice (D1 unchanged). Briefly considered
switching to Payment Sessions for richer state semantics, but: (a) the
xendit-nodeSDK does not yet support Payment Sessions (we would need raw HTTP calls), (b) Payment Sessions does not actually have a session-level FAILED state — FAILED only fires on the underlying per-attemptpayment.failureevent, weakening the original case for the switch, and (c) Ramadhan has an existing app already integrated against Invoice; consistency across products lowers operator burden. The payment service is product-agnostic, so a future migration to Payment Sessions remains a one-file change insidepayment.service.jsif/when the SDK adds support or a product needs subscriptions. - Pairing trigger: server-driven. Pairing service subscribes to
payment_request.confirmedand starts the mitra blast immediately when the event fires. Client app'sPOST /api/client/chat/requestcall is removed (or becomes a no-op when called for a payment that already triggered pairing). Phase 5 scope grows slightly but proves the event abstraction is load-bearing, and eliminates the race where a network drop between "customer sees confirmed" and "client calls chat/request" leaves them stranded.
Open threads to settle before rewriting Stages 1–7
- Stages 1–8 rewriting pass. The implementation prose still describes
the original chat-coupled, route-handler-direct-calls-Xendit shape. It
needs to be rewritten to incorporate: server-driven pairing subscriber,
payment-service wrapping Xendit (not route handler), product-agnostic
schema (
product_type+product_metadata), event emission + reconciliation sweeper, renamed identifiers. D1/D5/D8 stay correct (still Invoice). Mechanical but sizeable.
Out of scope (deferred)
- Recurring/subscription invoices (we sell discrete sessions)
- Refunds via API (manual via Xendit dashboard for now)v
- Disbursements / mitra payouts (Xendit auto-settles to the business account)
- Multi-currency (IDR only)
- Saved cards / one-click pay
- Webhook event audit table (add later if debugging needs it)
Source-of-truth references
- Existing payment service: backend/src/services/payment.service.js
- Existing payment routes: backend/src/routes/public/client.payment.routes.js
- Stub force-confirm: backend/src/routes/internal/_test.routes.js
- App confirm caller (will change): client_app/lib/core/chat/session_closure_notifier.dart
- Config getter pattern: backend/src/services/config.service.js
- Public-app route registration: backend/src/app.public.js
- Xendit Node SDK: xendit-node
- Invoice API docs: https://docs.xendit.co/invoice
- Webhook docs: https://docs.xendit.co/xenplatform/callbacks
Build Order (8 stages)
| Stage | Title | Outcome | Blocks on |
|---|---|---|---|
| 0 | Account & credentials prep | Test-mode keys + callback token + payment methods enabled in Xendit dashboard | — |
| 1 | Schema + identifier rename | payment_requests table with product_type, product_metadata, xendit_* columns; status enum includes failed_delivery + failed; FK columns + cause_tag values renamed throughout the codebase |
Stage 0 |
| 2 | Payment service: event emitter + Xendit wrapper | payment.service.js exposes the public API (requestPayment, confirmPayment, ..., on); Xendit SDK called only from inside it; events emit on terminal transitions |
Stage 1 |
| 3 | Webhook receiver | POST /api/shared/payment/webhooks/xendit — token verify, route inbound Xendit callbacks to confirmPayment / expirePayment (which then emit events) |
Stage 2 |
| 4 | Server-driven pairing subscriber | Pairing service subscribes to payment_request.confirmed; starts mitra blast when productType === 'chat_session'; idempotency check inside |
Stage 2 |
| 5 | Reconciliation sweeper + graceful shutdown | Extend the existing payment expiry sweeper to also re-trigger lost subscriber work; SIGTERM trap to drain in-flight handlers; startup reconciliation pass | Stage 4 |
| 6 | client_app cutover | Open invoice_url via Custom Tab (LaunchMode.inAppBrowserView); remove explicit POST /chat/request call; remove self-confirm call in session_closure_notifier.dart |
Stage 2 (backend smoke-testable without this) |
| 7 | Dev infra | Tunnel guide (cloudflared/ngrok), fake-webhook helper script, .env.example block |
Stage 3 |
| 8 | E2E + hardening | Real test-mode run end-to-end; idempotency / retry / amount-mismatch / SIGTERM / reconciliation tests; checklist | All prior |
Stages 0–5 are backend-only and curl-smoke-testable. Stage 6 cuts the app over. Stages 7–8 are verification and polish — none of them block shipping if the test-mode E2E in Stage 8 passes.
Sequence — happy-path payment flow (server-driven, event-based)
Module interactions from "Customer taps Bayar" to "App advances to searching." The dev /
Maestro flow (XENDIT_ENABLED=false) skips the Xendit branch entirely and
/internal/_test/force-confirm-payment plays the role of the webhook arm — same
downstream effect because the event emits from the same payment service method either way.
sequenceDiagram
autonumber
participant C as Customer
participant App as client_app
participant CT as Custom Tab<br/>(Chrome / SFVC)
participant BE as Backend (Fastify)
participant PS as payment.service
participant DB as PostgreSQL
participant X as Xendit
participant Pair as pairing.service
Note over PS,Pair: Phase 5: payment service owns Xendit;<br/>pairing subscribes to payment_request.confirmed.
C->>App: Tap Bayar on tier
App->>BE: POST /api/client/payment-requests<br/>{product_type:'chat_session', amount, product_metadata:{...}}
BE->>PS: requestPayment({productType, productMetadata, customerId, amount, ttlMinutes})
PS->>DB: INSERT payment_requests<br/>(status=pending, product_type, product_metadata, expires_at)
alt XENDIT_ENABLED=true
PS->>X: Invoice.createInvoice(external_id=row.id, amount, invoice_duration)
X-->>PS: {invoice_id, invoice_url}
PS->>DB: UPDATE xendit_invoice_id, xendit_invoice_url
else XENDIT_ENABLED=false (dev / Maestro)
Note over PS: invoice_url=null · stub will play the webhook role
end
PS-->>BE: paymentRequest (with invoice_url if Xendit on)
BE-->>App: 201 {id, invoice_url?, expires_at, ...}
opt invoice_url present
App->>CT: launchUrl(invoice_url, LaunchMode.inAppBrowserView)
CT->>X: GET hosted invoice page
C->>CT: Pick method (BCA / DANA / QRIS), pay
X-->>CT: success_redirect_url landing
C->>CT: Tap Done → app foreground
end
Note over App: Waiting-payment screen polls every ~2s
loop poll until status changes
App->>BE: GET /api/client/payment-requests/:id
BE-->>App: {status:'pending'}
end
X-)BE: POST /api/shared/payment/webhooks/xendit<br/>{external_id, status:'PAID', amount, payment_method}
BE->>PS: verifyWebhookToken → confirmPayment(id, xenditMeta)
PS->>DB: UPDATE status=confirmed, confirmed_at=NOW(), stamp xendit_*
PS-)Pair: emit('payment_request.confirmed', {paymentId, productType, productMetadata})
BE--)X: 200 OK (ACK in <2s; Xendit retries on 5xx / timeout)
Note over Pair: Subscriber filters productType==='chat_session'<br/>(future merch/course subscribers ignore this event)
Pair->>DB: idempotency check — chat_sessions WHERE payment_request_id=?
Pair->>DB: INSERT chat_sessions (status='searching', payment_request_id, ...)
Pair->>Pair: Start mitra blast (existing Phase 2 logic)
loop App keeps polling — sees confirmed + chat_session
App->>BE: GET /api/client/payment-requests/:id
BE-->>App: {status:'confirmed', chat_session_id}
Note over App: App switches to pairing-status polling.<br/>No explicit POST /chat/request — server already started it.
end
Variations (not drawn)
- Async payment (VA / retail outlet): customer leaves Xendit page after seeing the VA
number; pays at an ATM hours later. The app's polling loop keeps running for
payment_request_timeout_minutes. When the customer eventually pays, the webhook arm fires and unblocks the rest of the flow. - EXPIRED webhook: Xendit fires
status:'EXPIRED'wheninvoice_durationelapses unpaid. Handler callsexpirePayment(id)which mirrors the background sweeper — flipspending → expiredand emitspayment_request.expired. - Webhook retry: if our 200 OK is missed (>30s response or 5xx), Xendit re-fires.
confirmPaymentthrowsINVALID_STATEon the second delivery (row already confirmed); the handler swallows that specific error and ACKs. - createInvoice failure: Xendit API errors at request time.
requestPaymentmarks the rowfailed(notexpired— distinct state for "we never got a valid invoice"), emitspayment_request.failed, and returns 502 to the app. - Dev / Maestro: the webhook arm (and the Xendit invoice creation arm) are both
skipped.
POST /internal/_test/force-confirm-paymentcallspayment.confirmPayment(id)directly, which fires the same event chain that triggers pairing. Maestro flows keep working unchanged. - Lost event on process restart: event fired but pairing handler never ran. The reconciliation sweeper (Stage 5) catches the row on its next tick (≤60s) and re-invokes the handler. Subscriber idempotency makes this safe.
Stage 0 — Account & credentials prep
Owner: Ramadhan. Engineering can't start Stage 1 without these.
- Xendit account — register at https://dashboard.xendit.co (test mode is enabled by default; live mode unlocks after biz verification, not blocking Phase 5).
- Test mode keys. Dashboard → Settings → API Keys → Generate. Copy:
xnd_development_XXX(Secret Key — server)- Public key not needed (we use the hosted-page flow, not in-app SDK)
- Callback verification token. Dashboard → Settings → Callbacks → "Webhook Verification Token". Copy.
- Enable initial payment methods. Dashboard → Settings → Payment Methods. Recommend QRIS + at least one VA (BCA) + one e-wallet (DANA or OVO). Add more later without code changes.
- Live-mode docs (Finance / Legal) — not blocking dev. Required before prod cutover:
- NPWP, KTP direksi, akta, rekening koran
- Settlement bank account
.env additions (Stage 1 wires these up)
# Phase 5 — Xendit
XENDIT_ENABLED=false # flip to true in staging/prod
XENDIT_SECRET_KEY=xnd_development_xxx
XENDIT_WEBHOOK_TOKEN=xxx # dashboard → Settings → Callbacks
XENDIT_SUCCESS_REDIRECT_URL=https://halobestie.com/payment/success
XENDIT_FAILURE_REDIRECT_URL=https://halobestie.com/payment/failure
Redirect URLs are sent per-invoice (D8 caveat applies only to the webhook URL). They land the Custom Tab on a "you can close this" page; the app's waiting-payment screen drives the real UX via polling.
Stage 1 — Schema + identifier rename
This is the most invasive stage because it touches existing identifiers across backend + client_app. Doing it once cleanly avoids two rename passes.
1.1 Schema migration
Append to migrate.js as a new "Phase 5" block. Steps, all idempotent:
-- 1.1.1 Rename the table (if not already renamed on this DB)
DO $$
BEGIN
IF to_regclass('payment_sessions') IS NOT NULL AND to_regclass('payment_requests') IS NULL THEN
ALTER TABLE payment_sessions RENAME TO payment_requests;
END IF;
END $$;
-- 1.1.2 Rename indexes that Postgres auto-named after the table
ALTER INDEX IF EXISTS idx_payment_sessions_customer RENAME TO idx_payment_requests_customer;
ALTER INDEX IF EXISTS idx_payment_sessions_status_expires RENAME TO idx_payment_requests_status_expires;
-- 1.1.3 Rename FK columns on dependent tables
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name='chat_sessions' AND column_name='payment_session_id') THEN
ALTER TABLE chat_sessions RENAME COLUMN payment_session_id TO payment_request_id;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name='session_extensions' AND column_name='payment_session_id') THEN
ALTER TABLE session_extensions RENAME COLUMN payment_session_id TO payment_request_id;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name='pairing_failures' AND column_name='payment_session_id') THEN
ALTER TABLE pairing_failures RENAME COLUMN payment_session_id TO payment_request_id;
END IF;
END $$;
ALTER INDEX IF EXISTS idx_chat_sessions_payment RENAME TO idx_chat_sessions_payment_request;
-- 1.1.4 Rename the config key
UPDATE app_config
SET key='payment_request_timeout_minutes'
WHERE key='payment_session_timeout_minutes'
AND NOT EXISTS (SELECT 1 FROM app_config WHERE key='payment_request_timeout_minutes');
-- 1.1.5 Rewrite the status CHECK constraint:
-- - rename 'failed_pairing' value to 'failed_delivery'
-- - add 'failed' (createInvoice errored before customer paid)
ALTER TABLE payment_requests DROP CONSTRAINT IF EXISTS payment_sessions_status_check;
ALTER TABLE payment_requests DROP CONSTRAINT IF EXISTS payment_requests_status_check;
UPDATE payment_requests SET status='failed_delivery' WHERE status='failed_pairing';
ALTER TABLE payment_requests ADD CONSTRAINT payment_requests_status_check
CHECK (status IN ('pending','confirmed','consumed','expired','abandoned','failed','failed_delivery'));
-- 1.1.6 Rewrite the cause_tag CHECK on pairing_failures
ALTER TABLE pairing_failures DROP CONSTRAINT IF EXISTS pairing_failures_cause_tag_check;
UPDATE pairing_failures SET cause_tag='payment_request_expired' WHERE cause_tag='payment_session_expired';
ALTER TABLE pairing_failures ADD CONSTRAINT pairing_failures_cause_tag_check
CHECK (cause_tag IN (
'no_mitra_available','all_mitras_rejected','targeted_mitra_offline','targeted_mitra_rejected',
'targeted_mitra_timeout','payment_request_expired','customer_cancelled',
'extension_rejected','extension_safeguard_tripped'
));
-- 1.1.7 New columns: product_type, product_metadata (microservice-prep), xendit_*
ALTER TABLE payment_requests
ADD COLUMN IF NOT EXISTS product_type TEXT NOT NULL DEFAULT 'chat_session',
ADD COLUMN IF NOT EXISTS product_metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
ADD COLUMN IF NOT EXISTS xendit_invoice_id TEXT,
ADD COLUMN IF NOT EXISTS xendit_invoice_url TEXT,
ADD COLUMN IF NOT EXISTS xendit_payment_method TEXT,
ADD COLUMN IF NOT EXISTS xendit_paid_amount INTEGER;
-- 1.1.8 Partial unique index — webhook retries land on the same invoice_id; this
-- makes "already processed" detectable via a constraint violation.
CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_requests_xendit_invoice
ON payment_requests(xendit_invoice_id) WHERE xendit_invoice_id IS NOT NULL;
Also update the original CREATE TABLE payment_sessions block higher up in migrate.js
to use the new table name directly — that way a fresh DB skips the rename and the rename
block becomes a no-op on next run.
1.2 Backfill product_metadata for existing rows
-- Stamp product_metadata for any pre-Phase-5 rows so subscribers can read it uniformly
UPDATE payment_requests
SET product_metadata = jsonb_build_object(
'duration_minutes', duration_minutes,
'mode', mode,
'is_extension', is_extension,
'targeted_mitra_id', targeted_mitra_id
)
WHERE product_metadata = '{}'::jsonb AND product_type = 'chat_session';
1.3 Code renames
A mechanical pass across backend + client_app. Use the table from the Renames vs. the
original plan section above as the canonical list. Where
multiple identifiers collide (e.g. variable paymentSessionId and parameter payment_session_id),
do them by length descending so substring shadowing doesn't happen.
Files affected (non-exhaustive — grep payment_session / PaymentSession / createPaymentSession
across the repo):
backend/src/services/payment.service.js(function exports + internals)backend/src/services/pairing-failure.service.js(cause_tag value)backend/src/services/pairing.service.js(FK column reads)backend/src/services/config.service.js(config key + getter name)backend/src/services/chat.service.js(session-end refund path)backend/src/routes/public/client.payment.routes.js(route URL prefix + function calls)backend/src/routes/public/client.chat.routes.js(FK reads)backend/src/routes/internal/*.routes.js(test stub references)backend/test/**/*.test.js(test fixtures + assertions)client_app/lib/core/payment/payment_repository.dart(URL + model field names)client_app/lib/core/payment/payment_request_model.dart(or wherever the model lives)client_app/lib/features/payment/*.dart(BLoC state + UI string references)control_center/src/pages/PaymentRequestsPage.tsx(if it exists; otherwise skip)
After this stage, the codebase consistently uses payment_request everywhere. The
following stages assume this rename is complete.
Stage 2 — Payment service: event emitter + Xendit wrapper
payment.service.js becomes the single owner of the payment domain. After this stage:
no other file imports xendit-node, no other file SELECTs/UPDATEs payment_requests
directly, and the public API matches the contract documented in the architecture section
above.
2.1 Install Xendit SDK
cd backend && npm i xendit-node@^7
(Pin major; check npm view xendit-node version before installing.)
2.2 Env getter + boot validation
Append to config.service.js:
export const getXenditConfig = () => ({
enabled: process.env.XENDIT_ENABLED === 'true',
secretKey: process.env.XENDIT_SECRET_KEY ?? '',
webhookToken: process.env.XENDIT_WEBHOOK_TOKEN ?? '',
successRedirectUrl: process.env.XENDIT_SUCCESS_REDIRECT_URL ?? '',
failureRedirectUrl: process.env.XENDIT_FAILURE_REDIRECT_URL ?? '',
})
Boot validation in server.js, before initFirebase():
const xc = getXenditConfig()
if (xc.enabled) {
if (!xc.secretKey) throw new Error('XENDIT_ENABLED=true requires XENDIT_SECRET_KEY')
if (!xc.webhookToken || xc.webhookToken.length < 16) {
throw new Error('XENDIT_ENABLED=true requires XENDIT_WEBHOOK_TOKEN (>= 16 chars)')
}
}
2.3 EventEmitter inside payment.service.js
import { EventEmitter } from 'node:events'
const emitter = new EventEmitter()
// Allow more listeners than the default 10 in case multiple products subscribe to the same event
emitter.setMaxListeners(50)
// Public API: subscribers call this at app startup
export const on = (eventName, handler) => {
emitter.on(eventName, (payload) => {
// Wrap every handler so a throwing subscriber doesn't crash the process
// and so handlers run as fire-and-forget (don't block emit() returning)
Promise.resolve()
.then(() => handler(payload))
.catch((err) => console.error(`[payment event ${eventName}] handler failed`, err))
})
}
// Internal — called by the state-transition functions below
const emit = (eventName, payload) => emitter.emit(eventName, payload)
Events fire from inside confirmPayment / expirePayment / cancelPayment /
markDeliveryFailed AFTER the DB commit succeeds. Event payload shape:
{
paymentRequestId: string, // UUID
productType: string, // 'chat_session', etc
productMetadata: object, // the JSONB blob — whatever the product stamped
amount: number, // IDR rupiah
customerId: string, // UUID
// confirmed-only:
xenditInvoiceId?: string,
xenditPaymentMethod?: string,
}
2.4 Public API rewrite
Rewrite payment.service.js so the exports exactly match the contract:
// PUBLIC — these are the only functions other code may import.
export const requestPayment = async ({
productType,
productMetadata,
customerId,
amount,
ttlMinutes,
// chat-specific carry-throughs that stay top-level for now (legacy schema):
durationMinutes,
mode,
isFirstSessionDiscount,
isExtension,
targetedMitraId,
}) => {
// 1. INSERT row (status=pending)
const row = await sql`
INSERT INTO payment_requests (
customer_id, amount, duration_minutes, mode,
is_first_session_discount, is_extension, targeted_mitra_id,
product_type, product_metadata, status, expires_at
) VALUES (
${customerId}, ${amount}, ${durationMinutes}, ${mode},
${isFirstSessionDiscount}, ${isExtension}, ${targetedMitraId},
${productType}, ${sql.json(productMetadata)},
${PaymentRequestStatus.PENDING},
NOW() + (${ttlMinutes} || ' minutes')::interval
)
RETURNING *
`.then(r => r[0])
// 2. If Xendit on, create the invoice + stamp the row
const xc = getXenditConfig()
if (xc.enabled) {
try {
const { invoiceId, invoiceUrl } = await createXenditInvoice({
paymentRequestId: row.id,
amount: row.amount,
ttlMinutes,
description: buildDescriptionFor(row),
})
await sql`
UPDATE payment_requests
SET xendit_invoice_id = ${invoiceId}, xendit_invoice_url = ${invoiceUrl}
WHERE id = ${row.id}
`
row.xendit_invoice_id = invoiceId
row.xendit_invoice_url = invoiceUrl
} catch (err) {
// Distinct terminal state — "we never got an invoice." NOT expired.
await sql`
UPDATE payment_requests
SET status = ${PaymentRequestStatus.FAILED}
WHERE id = ${row.id} AND status = ${PaymentRequestStatus.PENDING}
`
emit('payment_request.failed', toEventPayload(row))
throw Object.assign(new Error('Payment provider error'), {
code: 'PAYMENT_PROVIDER_ERROR',
statusCode: 502,
cause: err,
})
}
}
return row
}
export const confirmPayment = async (paymentRequestId, xenditMeta = {}) => {
// State transition pending → confirmed (idempotency: throws INVALID_STATE if not pending)
const row = await transitionPending(paymentRequestId, PaymentRequestStatus.CONFIRMED, { confirmed_at: 'NOW()' })
if (xenditMeta.invoiceId || xenditMeta.paymentMethod || xenditMeta.amount) {
await sql`
UPDATE payment_requests
SET xendit_invoice_id = COALESCE(${xenditMeta.invoiceId ?? null}, xendit_invoice_id),
xendit_payment_method = ${xenditMeta.paymentMethod ?? null},
xendit_paid_amount = ${xenditMeta.amount ?? null}
WHERE id = ${paymentRequestId}
`
}
emit('payment_request.confirmed', toEventPayload(row))
return row
}
export const expirePayment = async (paymentRequestId) => {
const row = await transitionPending(paymentRequestId, PaymentRequestStatus.EXPIRED)
if (row) emit('payment_request.expired', toEventPayload(row))
return row
}
export const cancelPayment = async (paymentRequestId, customerId) => {
// Customer-initiated; check ownership
const row = await transitionPendingWithOwner(paymentRequestId, customerId, PaymentRequestStatus.ABANDONED)
emit('payment_request.cancelled', toEventPayload(row))
return row
}
export const markDeliveryFailed = async (paymentRequestId, causeTag) => {
// Confirmed → failed_delivery; called by product code (pairing, future merch service)
const row = await transitionConfirmed(paymentRequestId, PaymentRequestStatus.FAILED_DELIVERY)
await recordFailure({ paymentRequestId, causeTag, ... })
emit('payment_request.delivery_failed', { ...toEventPayload(row), causeTag })
return row
}
export const consumePayment = async (paymentRequestId) => {
// Confirmed → consumed; called by product code when delivery succeeds
// No event — terminal success, nothing else needs to react
return transitionConfirmed(paymentRequestId, PaymentRequestStatus.CONSUMED, { consumed_at: 'NOW()' })
}
export const getPayment = async (id) => {
const [row] = await sql`SELECT * FROM payment_requests WHERE id = ${id}`
return row ?? null
}
export { on } // event subscription
2.5 Internal Xendit wrapper
Private helpers inside payment.service.js (do NOT export):
import { Xendit } from 'xendit-node'
let _client = null
const xenditClient = () => {
if (_client) return _client
_client = new Xendit({ secretKey: getXenditConfig().secretKey })
return _client
}
const createXenditInvoice = async ({ paymentRequestId, amount, ttlMinutes, description }) => {
const inv = await xenditClient().Invoice.createInvoice({
data: {
externalId: paymentRequestId,
amount,
description,
invoiceDuration: Math.floor(ttlMinutes * 60),
currency: 'IDR',
successRedirectUrl: getXenditConfig().successRedirectUrl || undefined,
failureRedirectUrl: getXenditConfig().failureRedirectUrl || undefined,
// paymentMethods: null → honor dashboard config
},
})
return { invoiceId: inv.id, invoiceUrl: inv.invoiceUrl }
}
export const verifyWebhookToken = (headerToken) => {
const { webhookToken } = getXenditConfig()
if (!headerToken || !webhookToken) return false
if (typeof headerToken !== 'string') return false
if (headerToken.length !== webhookToken.length) return false
let mismatch = 0
for (let i = 0; i < headerToken.length; i++) {
mismatch |= headerToken.charCodeAt(i) ^ webhookToken.charCodeAt(i)
}
return mismatch === 0
}
verifyWebhookToken is exported because the webhook route (Stage 3) calls it. It's the
one Xendit-aware helper the route layer is allowed to use.
2.6 Gate POST /payment-requests/:id/confirm on XENDIT_ENABLED
app.post('/:id/confirm', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
if (getXenditConfig().enabled) {
return reply.code(403).send({
success: false,
error: { code: 'FORBIDDEN', message: 'Confirmation must come from Xendit webhook' },
})
}
const row = await confirmPayment(request.params.id, {})
return reply.send({ success: true, data: { id: row.id, status: row.status, confirmed_at: row.confirmed_at } })
})
/internal/_test/force-confirm-payment (in _test.routes.js) keeps using
confirmPayment directly — it lives on the internal listener and bypasses the gate. The
test stub call should be updated to use the new confirmPayment signature (id, {}).
Stage 3 — Webhook receiver
3.1 New route file
Create backend/src/routes/public/shared.payment-webhooks.routes.js:
import { verifyWebhookToken, confirmPayment, expirePayment, getPayment } from '../../services/payment.service.js'
export const paymentWebhookRoutes = async (app) => {
app.post('/webhooks/xendit', async (request, reply) => {
const token = request.headers['x-callback-token']
if (!verifyWebhookToken(token)) {
request.log.warn('xendit webhook: bad token')
return reply.code(401).send({ error: 'invalid_token' })
}
const { id: invoiceId, external_id: paymentRequestId, status, amount, payment_method } = request.body ?? {}
request.log.info({ paymentRequestId, invoiceId, status, amount }, 'xendit webhook received')
if (!paymentRequestId) {
return reply.send({ ok: true, ignored: 'no_external_id' }) // forward-compat
}
const existing = await getPayment(paymentRequestId)
if (!existing) {
request.log.warn({ paymentRequestId, invoiceId }, 'unknown payment_request — ACKing so Xendit stops retrying')
return reply.send({ ok: true, ignored: 'unknown_payment_request' })
}
if (status === 'PAID') {
if (typeof amount === 'number' && amount !== existing.amount) {
request.log.error({ paymentRequestId, expected: existing.amount, got: amount }, 'amount mismatch')
return reply.code(409).send({ error: 'amount_mismatch' })
}
try {
await confirmPayment(paymentRequestId, { invoiceId, paymentMethod: payment_method, amount })
} catch (err) {
if (err.code === 'INVALID_STATE' || err.code === 'CONFLICT') {
request.log.info({ paymentRequestId, code: err.code, prevStatus: existing.status }, 'already terminal, ACKing')
} else if (err.code === 'EXPIRED') {
request.log.error({ paymentRequestId, expiredAt: existing.expires_at }, 'PAID after expiry — manual recovery needed')
} else {
throw err
}
}
return reply.send({ ok: true })
}
if (status === 'EXPIRED') {
await expirePayment(paymentRequestId)
return reply.send({ ok: true })
}
return reply.send({ ok: true, ignored: status }) // forward-compat for future Xendit event types
})
}
3.2 Register the route
In app.public.js:
import { paymentWebhookRoutes } from './routes/public/shared.payment-webhooks.routes.js'
// ...
app.register(paymentWebhookRoutes, { prefix: '/api/shared/payment' })
Final URL: POST /api/shared/payment/webhooks/xendit. The path signals that Xendit is a
specific provider behind the payment service — a future Midtrans webhook would land at
/api/shared/payment/webhooks/midtrans.
3.3 Test coverage
Vitest covering: bad token (401), happy PAID (200 + row confirmed + xendit cols stamped),
amount mismatch (409), retry idempotency (second PAID with same invoice → 200 ACK,
row stays confirmed), EXPIRED, unknown payment_request (200 ignored), unhandled status
(200 ignored). See test/routes/shared.payment-webhooks.routes.test.js.
Stage 4 — Server-driven pairing subscriber
4.1 Restructure pairing entry point
Pull the existing chat-blast logic into a function that takes only the payment row + its metadata. In pairing.service.js:
export const startPairingFromPaymentRequest = async ({ paymentRequestId, productMetadata, customerId }) => {
// Idempotency — safe re-run from reconciliation sweeper or webhook retry
const [existing] = await sql`
SELECT id FROM chat_sessions WHERE payment_request_id = ${paymentRequestId}
`
if (existing) return existing
// Build chat_sessions row using product_metadata (no longer reading from payment_requests's
// legacy chat-specific columns — eventually those move into product_metadata too)
const { duration_minutes, mode, targeted_mitra_id, is_extension } = productMetadata
const [chatSession] = await sql`
INSERT INTO chat_sessions (
customer_id, status, duration_minutes, mode,
payment_request_id, is_first_session_discount
) VALUES (
${customerId}, 'searching', ${duration_minutes}, ${mode},
${paymentRequestId}, ...
)
RETURNING *
`
// Existing blast logic (Phase 2)
await blastToAvailableMitras({ chatSessionId: chatSession.id, targetedMitraId: targeted_mitra_id })
return chatSession
}
4.2 Register the subscriber at app startup
In server.js, after building both apps and before
listen() (or in a dedicated subscriber-registration module called from there):
import * as payment from './services/payment.service.js'
import { startPairingFromPaymentRequest } from './services/pairing.service.js'
payment.on('payment_request.confirmed', async (evt) => {
if (evt.productType !== 'chat_session') return // future merch/course handlers filter their own
await startPairingFromPaymentRequest({
paymentRequestId: evt.paymentRequestId,
productMetadata: evt.productMetadata,
customerId: evt.customerId,
})
})
4.3 Remove the explicit chat-request call (backend side)
POST /api/client/chat/request becomes a no-op when the payment row already has a
chat_session — so legacy clients (if any) calling it get a 200 with the existing chat
session id. After client_app cutover (Stage 6) the endpoint can be removed entirely
(follow-up phase, not Phase 5 scope).
4.4 Confirm extension flow
The extension flow (Lanjut Curhat) also creates a payment_request; same subscriber
handles it via a productMetadata flag (is_extension: true). The handler branches to
the extension logic instead of starting a fresh blast. Verify the existing extension
service code path is callable from this subscriber.
Stage 5 — Reconciliation sweeper + graceful shutdown
5.1 Extend the existing payment sweeper
In payment.service.js, update
expireStalePaymentRequests (renamed from expireStalePaymentSessions) to also dispatch
lost subscriber work:
export const expireStalePaymentRequests = async () => {
// ... existing expire pending past expires_at logic ...
// ... existing confirmed-but-stale → failed_delivery logic ...
// NEW: reconciliation — confirmed payments that never started their product work
const orphaned = await sql`
SELECT id, customer_id, product_type, product_metadata, amount
FROM payment_requests
WHERE status = 'confirmed'
AND confirmed_at < NOW() - INTERVAL '30 seconds'
AND product_type = 'chat_session'
AND NOT EXISTS (SELECT 1 FROM chat_sessions cs WHERE cs.payment_request_id = payment_requests.id)
AND NOT EXISTS (SELECT 1 FROM pairing_failures pf WHERE pf.payment_request_id = payment_requests.id)
LIMIT 100 -- bound per-tick work
`
for (const row of orphaned) {
try {
await startPairingFromPaymentRequest({
paymentRequestId: row.id,
productMetadata: row.product_metadata,
customerId: row.customer_id,
})
console.log(`[reconciler] re-triggered pairing for ${row.id}`)
} catch (err) {
console.error(`[reconciler] failed to re-trigger ${row.id}`, err)
}
}
return { ...existingCounts, reconciled: orphaned.length }
}
The 30-second buffer avoids racing with happy-path subscribers that are mid-flight.
5.2 Run reconciliation at startup
In server.js, after restoreActiveTimers():
// Catch anything missed during a restart window
await expireStalePaymentRequests().catch(err => console.error('[reconciler] startup pass failed', err))
5.3 SIGTERM trap
In server.js, before start().catch(...):
const shutdown = async () => {
console.log('SIGTERM received — closing servers, draining handlers')
await Promise.allSettled([publicApp.close(), internalApp.close()])
// Give EventEmitter handlers up to 8s to finish (Cloud Run grace ~10s)
await new Promise(r => setTimeout(r, 8_000))
process.exit(0)
}
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
Stage 6 — client_app cutover
6.1 Read invoice_url from response
In payment_repository.dart,
parse data.invoice_url from the POST /payment-requests response and add it to the
payment-request model.
6.2 Open Custom Tab on receipt
In the screen that immediately follows payment creation (waiting-payment screen):
import 'package:url_launcher/url_launcher.dart';
if (paymentRequest.invoiceUrl != null) {
await launchUrl(
Uri.parse(paymentRequest.invoiceUrl!),
mode: LaunchMode.inAppBrowserView, // Custom Tab on Android, SFVC on iOS
);
}
// Either way (Xendit live or stub), show waiting-payment UI and poll.
When invoice_url is null (dev / XENDIT_ENABLED=false), the screen sits exactly as it
does today — the force-confirm-payment stub flips the row and polling picks it up.
Verify that url_launcher is already a dependency (it is, used elsewhere in the app).
6.3 Remove the explicit chat-request call
After polling sees status=confirmed, the app should NOT call POST /chat/request anymore
— the server-side subscriber has already started pairing. Instead:
- Find the call in the post-confirmed flow (currently in PaymentBloc or equivalent)
- Replace with a poll on the chat-session status (extend the existing polling to also
return
chat_session_idwhen available, or add a separate endpoint)
6.4 Remove the self-confirm call
In session_closure_notifier.dart:75
the app currently calls POST /payment-requests/:id/confirm during chat-closure rebill.
With Xendit on, this returns 403. Remove the call; rely on polling.
6.5 Optional: success-page deep-link return
Out of Phase 5 scope. Today the user taps Done on the Custom Tab to come back; later we
could register a halobestie://payment/return URL scheme and pass it as
successRedirectUrl so they bounce back automatically.
Stage 7 — Dev infra
7.1 Tunnel for webhook reachability
Pick one:
-
cloudflared (recommended — free, can be made static):
cloudflared tunnel --url http://localhost:3000Pin the URL via a named tunnel + DNS record in the Cloudflare dashboard so it survives restarts.
-
ngrok — fastest to start; free tier rotates URL on restart:
ngrok http 3000
Register the tunnel URL in Xendit Test Mode → Settings → Callbacks → Invoice Paid / Invoice Expired:
https://<tunnel>/api/shared/payment/webhooks/xendit
Document this in backend/CLAUDE.md under a new "Phase 5 dev setup" section so the next person doesn't have to re-derive it.
7.2 Fake-webhook helper script
backend/.dev/xendit-fake-webhook.sh:
#!/usr/bin/env bash
# Fire a fake Xendit Invoice callback at the local backend.
# Usage: ./xendit-fake-webhook.sh <payment_request_id> [PAID|EXPIRED] [amount]
set -euo pipefail
PAYMENT_ID="${1:?usage}"
STATUS="${2:-PAID}"
AMOUNT="${3:-50000}"
TOKEN="${XENDIT_WEBHOOK_TOKEN:?XENDIT_WEBHOOK_TOKEN env not set}"
BASE_URL="${BASE_URL:-http://localhost:3000}"
curl -sS -X POST "${BASE_URL}/api/shared/payment/webhooks/xendit" \
-H "x-callback-token: ${TOKEN}" \
-H "content-type: application/json" \
-d "{
\"id\": \"inv_fake_$(date +%s)_${RANDOM}\",
\"external_id\": \"${PAYMENT_ID}\",
\"status\": \"${STATUS}\",
\"amount\": ${AMOUNT},
\"payment_method\": \"BCA\"
}" | jq . 2>/dev/null || cat
echo
Lets developers exercise the webhook handler without ngrok / Xendit. NOT a Maestro
replacement — Maestro continues to use /internal/_test/force-confirm-payment.
7.3 .env.example block
Append to backend/.env.example:
# Phase 5 — Xendit (dev-safe defaults: integration disabled)
XENDIT_ENABLED=false
XENDIT_SECRET_KEY=
XENDIT_WEBHOOK_TOKEN=
XENDIT_SUCCESS_REDIRECT_URL=
XENDIT_FAILURE_REDIRECT_URL=
Stage 8 — E2E verification + hardening checklist
8.1 End-to-end smoke test (run once before declaring Phase 5 done)
XENDIT_ENABLED=true+ real test-mode keys + cloudflared tunnel up + dashboard webhook URL registered.- Launch client_app pointed at local backend (
API_BASE_URL=http://192.168.88.247:3000per [memory feedback_flutter_run_api_base_url]). - Onboard fresh customer → "Mulai Curhat" → pick tier → tap pay.
- Verify: backend logs show
createInvoicesucceeded; app opens Xendit URL in Custom Tab. - On Xendit's hosted page: pick a method, in Xendit dashboard → Transactions click "Simulate Payment".
- Verify: webhook hit in logs;
payment_requests.status='confirmed'; server-side pairing subscriber fired;chat_sessionsrow created withpayment_request_idset; app's polling advances to searching.
8.2 Failure-path tests
- Retry idempotency: click Simulate Payment twice → second webhook → ACKs without second-confirm error; row stays confirmed; only one chat_session exists.
- Expiry: create a request, let
invoice_durationelapse → Xendit fires EXPIRED → row goesexpired;payment_request.expiredevent fires (visible in logs). - Amount tamper: edit DB row to set
amount=1; simulate PAID withamount=50000→ webhook returns 409; row stays unconfirmed. - createInvoice failure: temporarily set
XENDIT_SECRET_KEYto a bad value; tap pay → backend returns 502 withPAYMENT_PROVIDER_ERROR; row markedfailed;payment_request.failedevent fired. - Reconciliation: disable the pairing subscriber temporarily; trigger a successful
payment; verify
payment_requests.status='confirmed'but nochat_sessionsrow; wait 60s; verify the sweeper re-triggered pairing andchat_sessionsrow now exists. - SIGTERM drain: start a payment, hit
force-confirm-paymentto fire the event; immediatelykill -TERM <pid>; verify the pairing handler completes before process exits (chat_sessions row created); restart and verify nothing duplicated.
Record results in phase5-test-run-<date>.md following the
phase3.7-test-run-2026-05-03.md template.
8.3 Pre-ship hardening checklist
- Webhook ACKs in <2s. Xendit retries on 5xx / timeouts (>30s). Confirm by tailing logs during a Simulate Payment burst.
- Idempotent confirm under race. Two webhook deliveries within 100ms → second one
hits
INVALID_STATE→ swallowed → ACK. Unit test intest/services/payment.service.test.js. - Unknown payment_request webhook. ACK with
{ignored: 'unknown_payment_request'}so Xendit stops retrying orphan webhooks. Already in handler. - Webhook ordering — PAID before our row knows its
xendit_invoice_id. QRIS can confirm in <500ms. Handler looks up the row byexternal_id(= our UUID), not byxendit_invoice_id, so order doesn't matter. The stamping UPDATE insideconfirmPaymentuses the invoice id from the webhook body. - PAID arriving after expiry sweeper. D5 (
invoice_duration === payment_request_timeout_minutes) keeps these aligned. The handler'sEXPIREDerror catch logs aterrorlevel so we notice if it ever fires in production. - Boot-time validation. Already in Stage 2.2. Tested by setting
XENDIT_ENABLED=truewith emptyXENDIT_SECRET_KEYand asserting boot fails. - Log volume in prod. Webhook entries at
info; ignored/unknown atwarn; mismatch / EXPIRED-after-confirm aterror. The high-volume health-check path stays atinfo. - Refund process documented. When
payment_request.delivery_failedfires, operator sees the row in CC, manually refunds via Xendit dashboard, marksoperator_action='refunded'in CC. No automated refund in Phase 5 — doc only.
Effort estimate
| Stage | Effort | Notes |
|---|---|---|
| 0 | 30 min | Ramadhan, account + dashboard setup |
| 1 | 3–4 hrs | Migration + identifier rename across backend + client_app + tests |
| 2 | 3–4 hrs | Payment service rewrite (event emitter + Xendit wrap + state-transition refactor + tests) |
| 3 | 1–2 hrs | Webhook receiver + tests |
| 4 | 1–2 hrs | Pairing subscriber + entry-point refactor |
| 5 | 1 hr | Sweeper extension + SIGTERM + startup pass |
| 6 | 1–2 hrs | client_app cutover (Custom Tab + remove chat-request + remove self-confirm) |
| 7 | 1 hr | Tunnel setup + helper script + .env.example |
| 8 | 2–3 hrs | Real E2E + 6 failure-path tests + hardening checklist |
Total: ~2–2.5 days of focused work. Stage 1 is the biggest because the rename touches the most files. Stages 2–5 are the architectural core. Stages 6–8 are the user-facing + ops polish.