# 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.