# 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 of `pending → confirmed`.
>
> Affects: **backend**, **client_app**, **control_center** (minor).
> `mitra_app` is untouched — mitras never pay.
>
> See [phase4-customer-flow-plan.md](phase4-customer-flow-plan.md) for the payment-shell
> work this builds on, and root `CLAUDE.md` for 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
```sql
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 when `payment_request.confirmed` fires. 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.
```mermaid
flowchart LR
subgraph Triggers["Triggers (cause state transitions)"]
XPaid[Xendit PAID webhook]
XExp[Xendit EXPIRED webhook]
Sweep[Background sweeper
pending past expires_at]
Stub[Dev force-confirm stub
/internal/_test/force-confirm-payment]
Cancel[Customer cancel button]
DelFail[Product service reports
delivery failed
pairing: no mitra · merch: oos · ...]
end
PS{{Payment Service
requestPayment · confirmPayment ·
expirePayment · cancelPayment ·
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
filter productType=chat_session
read productMetadata
start mitra blast]
Ops[Operator workflow
refund / re-credit queue]
Future[Future product services
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`
```mermaid
stateDiagram-v2
[*] --> pending: requestPayment()
row inserted · Xendit invoice created
(no event — caller has the id)
pending --> confirmed: confirmPayment()
emits payment_request.confirmed
pending --> expired: expirePayment()
emits payment_request.expired
pending --> cancelled: cancelPayment()
emits payment_request.cancelled
confirmed --> consumed: consumePayment()
(product code on successful delivery)
(no event — terminal success)
confirmed --> failed_delivery: markDeliveryFailed()
emits payment_request.delivery_failed
consumed --> [*]
expired --> [*]
cancelled --> [*]
failed_delivery --> [*]
```
**Notes on the lifecycle:**
- **No event at `pending` insertion.** The caller of `requestPayment` already
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.expired` fires 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_delivery` is the renamed `failed_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:**
1. **EventEmitter as the trigger.** After `confirmPayment()` commits the DB
transaction, fire `payment_request.confirmed`. Subscribers (pairing for
`chat_session`, future product services for their own types) react
immediately on the happy path.
2. **Subscribers are idempotent.** Every handler starts by checking "is the
work this event represents already done?" before doing anything. For the
pairing subscriber:
```js
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.
3. **Reconciliation sweeper extends the existing payment expiry sweeper**
(which already runs every minute in `server.js`). New query, per product:
```sql
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.
4. **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:
```js
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 a `payment_requests.status` value. 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_delivery`
for all products for now. Revisit when a product emerges that genuinely
needs additional terminal states (e.g. `shipped` for 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-node` SDK 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-attempt `payment.failure` event, 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 inside `payment.service.js`
if/when the SDK adds support or a product needs subscriptions.
- **Pairing trigger: server-driven.** Pairing service subscribes to
`payment_request.confirmed` and starts the mitra blast immediately when
the event fires. Client app's `POST /api/client/chat/request` call 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
1. **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](../backend/src/services/payment.service.js)
- Existing payment routes: [backend/src/routes/public/client.payment.routes.js](../backend/src/routes/public/client.payment.routes.js)
- Stub force-confirm: [backend/src/routes/internal/_test.routes.js](../backend/src/routes/internal/_test.routes.js)
- App confirm caller (will change): [client_app/lib/core/chat/session_closure_notifier.dart](../client_app/lib/core/chat/session_closure_notifier.dart)
- Config getter pattern: [backend/src/services/config.service.js](../backend/src/services/config.service.js)
- Public-app route registration: [backend/src/app.public.js](../backend/src/app.public.js)
- Xendit Node SDK: [xendit-node](https://github.com/xendit/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.
```mermaid
sequenceDiagram
autonumber
participant C as Customer
participant App as client_app
participant CT as Custom Tab
(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;
pairing subscribes to payment_request.confirmed.
C->>App: Tap Bayar on tier
App->>BE: POST /api/client/payment-requests
{product_type:'chat_session', amount, product_metadata:{...}}
BE->>PS: requestPayment({productType, productMetadata, customerId, amount, ttlMinutes})
PS->>DB: INSERT payment_requests
(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
{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'
(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.
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'` when `invoice_duration` elapses
unpaid. Handler calls `expirePayment(id)` which mirrors the background sweeper — flips
`pending → expired` and emits `payment_request.expired`.
- **Webhook retry:** if our 200 OK is missed (>30s response or 5xx), Xendit re-fires.
`confirmPayment` throws `INVALID_STATE` on the second delivery (row already confirmed);
the handler swallows that specific error and ACKs.
- **createInvoice failure:** Xendit API errors at request time. `requestPayment` marks
the row `failed` (not `expired` — distinct state for "we never got a valid invoice"),
emits `payment_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-payment` calls `payment.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.
1. **Xendit account** — register at https://dashboard.xendit.co (test mode is enabled by default; live mode unlocks after biz verification, not blocking Phase 5).
2. **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)
3. **Callback verification token.** Dashboard → Settings → Callbacks → "Webhook Verification Token". Copy.
4. **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.
5. **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)
```bash
# 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](../backend/src/db/migrate.js) as a new "Phase 5" block. Steps,
all idempotent:
```sql
-- 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
```sql
-- 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](#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
```bash
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](../backend/src/services/config.service.js):
```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](../backend/src/server.js), before `initFirebase()`:
```js
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`
```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:
```js
{
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](../backend/src/services/payment.service.js) so the exports
exactly match the contract:
```js
// 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):
```js
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`
In [client.payment.routes.js](../backend/src/routes/public/client.payment.routes.js):
```js
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`:
```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](../backend/src/app.public.js):
```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](../backend/src/services/pairing.service.js):
```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](../backend/src/server.js), after building both apps and before
`listen()` (or in a dedicated subscriber-registration module called from there):
```js
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](../backend/src/services/payment.service.js), update
`expireStalePaymentRequests` (renamed from `expireStalePaymentSessions`) to also dispatch
lost subscriber work:
```js
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()`:
```js
// 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(...)`:
```js
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](../client_app/lib/core/payment/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):
```dart
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_id` when available, or add a separate endpoint)
## 6.4 Remove the self-confirm call
In [session_closure_notifier.dart:75](../client_app/lib/core/chat/session_closure_notifier.dart#L75)
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:
1. **cloudflared** (recommended — free, can be made static):
```bash
cloudflared tunnel --url http://localhost:3000
```
Pin the URL via a named tunnel + DNS record in the Cloudflare dashboard so it survives
restarts.
2. **ngrok** — fastest to start; free tier rotates URL on restart:
```bash
ngrok http 3000
```
Register the tunnel URL in Xendit Test Mode → Settings → Callbacks → Invoice Paid /
Invoice Expired:
```
https:///api/shared/payment/webhooks/xendit
```
Document this in [backend/CLAUDE.md](../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`:
```bash
#!/usr/bin/env bash
# Fire a fake Xendit Invoice callback at the local backend.
# Usage: ./xendit-fake-webhook.sh [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`:
```bash
# 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)
1. `XENDIT_ENABLED=true` + real test-mode keys + cloudflared tunnel up + dashboard webhook
URL registered.
2. Launch client_app pointed at local backend (`API_BASE_URL=http://192.168.88.247:3000`
per [memory feedback_flutter_run_api_base_url]).
3. Onboard fresh customer → "Mulai Curhat" → pick tier → tap pay.
4. Verify: backend logs show `createInvoice` succeeded; app opens Xendit URL in Custom Tab.
5. On Xendit's hosted page: pick a method, in Xendit dashboard → Transactions click
"Simulate Payment".
6. Verify: webhook hit in logs; `payment_requests.status='confirmed'`; **server-side
pairing subscriber fired**; `chat_sessions` row created with `payment_request_id` set;
app's polling advances to searching.
## 8.2 Failure-path tests
7. **Retry idempotency:** click Simulate Payment twice → second webhook → ACKs without
second-confirm error; row stays confirmed; only one chat_session exists.
8. **Expiry:** create a request, let `invoice_duration` elapse → Xendit fires EXPIRED →
row goes `expired`; `payment_request.expired` event fires (visible in logs).
9. **Amount tamper:** edit DB row to set `amount=1`; simulate PAID with `amount=50000` →
webhook returns 409; row stays unconfirmed.
10. **createInvoice failure:** temporarily set `XENDIT_SECRET_KEY` to a bad value; tap pay
→ backend returns 502 with `PAYMENT_PROVIDER_ERROR`; row marked `failed`;
`payment_request.failed` event fired.
11. **Reconciliation:** disable the pairing subscriber temporarily; trigger a successful
payment; verify `payment_requests.status='confirmed'` but no `chat_sessions` row; wait
60s; verify the sweeper re-triggered pairing and `chat_sessions` row now exists.
12. **SIGTERM drain:** start a payment, hit `force-confirm-payment` to fire the event;
immediately `kill -TERM `; verify the pairing handler completes before process
exits (chat_sessions row created); restart and verify nothing duplicated.
Record results in `phase5-test-run-.md` following the
[phase3.7-test-run-2026-05-03.md](phase3.7-test-run-2026-05-03.md) template.
## 8.3 Pre-ship hardening checklist
1. **Webhook ACKs in <2s.** Xendit retries on 5xx / timeouts (>30s). Confirm by tailing
logs during a Simulate Payment burst.
2. **Idempotent confirm under race.** Two webhook deliveries within 100ms → second one
hits `INVALID_STATE` → swallowed → ACK. Unit test in `test/services/payment.service.test.js`.
3. **Unknown payment_request webhook.** ACK with `{ignored: 'unknown_payment_request'}`
so Xendit stops retrying orphan webhooks. Already in handler.
4. **Webhook ordering — PAID before our row knows its `xendit_invoice_id`.** QRIS can
confirm in <500ms. Handler looks up the row by `external_id` (= our UUID), not by
`xendit_invoice_id`, so order doesn't matter. The stamping UPDATE inside `confirmPayment`
uses the invoice id from the webhook body.
5. **PAID arriving after expiry sweeper.** D5 (`invoice_duration === payment_request_timeout_minutes`)
keeps these aligned. The handler's `EXPIRED` error catch logs at `error` level so we
notice if it ever fires in production.
6. **Boot-time validation.** Already in Stage 2.2. Tested by setting `XENDIT_ENABLED=true`
with empty `XENDIT_SECRET_KEY` and asserting boot fails.
7. **Log volume in prod.** Webhook entries at `info`; ignored/unknown at `warn`; mismatch
/ EXPIRED-after-confirm at `error`. The high-volume health-check path stays at `info`.
8. **Refund process documented.** When `payment_request.delivery_failed` fires, operator
sees the row in CC, manually refunds via Xendit dashboard, marks `operator_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.