Files
halobestie-clone/requirement/phase5-xendit-plan.md
Ramadhan Sjamsani 3fff4b1c6e Phase 5 Xendit: Stages 1-7 (XENDIT_ENABLED=false; Stage 8 pending creds)
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>
2026-05-25 12:52:33 +08:00

1327 lines
58 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 17 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 D1D9 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<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`
```mermaid
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 `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 17
1. **Stages 18 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 05 are backend-only and curl-smoke-testable. Stage 6 cuts the app over. Stages 78 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<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'` 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://<tunnel>/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 <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`:
```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 <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](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 | 34 hrs | Migration + identifier rename across backend + client_app + tests |
| 2 | 34 hrs | Payment service rewrite (event emitter + Xendit wrap + state-transition refactor + tests) |
| 3 | 12 hrs | Webhook receiver + tests |
| 4 | 12 hrs | Pairing subscriber + entry-point refactor |
| 5 | 1 hr | Sweeper extension + SIGTERM + startup pass |
| 6 | 12 hrs | client_app cutover (Custom Tab + remove chat-request + remove self-confirm) |
| 7 | 1 hr | Tunnel setup + helper script + .env.example |
| 8 | 23 hrs | Real E2E + 6 failure-path tests + hardening checklist |
**Total: ~22.5 days of focused work.** Stage 1 is the biggest because the rename touches
the most files. Stages 25 are the architectural core. Stages 68 are the user-facing +
ops polish.