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