Files
halobestie-clone/requirement/phase5-xendit-plan.md
Ramadhan Sjamsani 3fff4b1c6e Phase 5 Xendit: Stages 1-7 (XENDIT_ENABLED=false; Stage 8 pending creds)
Backend
- payment_sessions → payment_requests rename across DB schema + 29 files
- payment.service.js becomes product-agnostic owner: EventEmitter +
  Xendit wrapper + requestPayment / confirmPayment public API; legacy
  aliases retained for existing chat callers
- Webhook handler at POST /api/shared/payment/webhooks/xendit, with
  constant-time token verification (8 vitest cases)
- Server-driven pairing: payment.service emits
  payment_request.confirmed → pairing subscriber starts the blast.
  Legacy POST /chat/request still works during the cutover.
- Reconciliation sweeper extended (re-emits events for confirmed rows
  with no chat session)
- SIGTERM drain + startup reconciliation pass in server.js

Customer app
- waiting_payment_screen opens xendit_invoice_url via
  LaunchMode.inAppBrowserView
- searching / no-bestie / targeted-waiting / pairing-notifier updated
  to consume the new payment_request_id contract
- pending_payments_provider + bestie-unavailable dialog migrated

Dev / testing
- XENDIT_ENABLED=false is the safe default; .env.example documents the
  four new vars
- backend/.dev/xendit-fake-webhook.sh exercises the handler without
  ngrok
- 90/92 backend tests pass (two pre-existing session-timer flakes,
  unrelated); client_app analyzer clean
- requirement/phase5-xendit-plan.md is the canonical reference

Stage 8 (live E2E) blocked on Xendit test-mode keys. The dashboard's
single-webhook-URL constraint will be worked around via a self-poll
script next session.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 12:52:33 +08:00

58 KiB
Raw Blame History

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 for the payment-shell work this builds on, and root CLAUDE.md for project context.


Architectural Decisions (locked)

# Decision Rationale
D1 Use Xendit Invoice product (hosted page) One product covers all payment methods; ~1 day to ship vs. ~1 week for per-method PG APIs.
D2 Webhook is the only caller of confirmPayment Required for VA (async, customer pays hours later). App polls GET /payment-requests/:id.
D3 App opens the Xendit checkout URL in an in-app browser (Chrome Custom Tab on Android, SFSafariViewController on iOS) via url_launcher LaunchMode.inAppBrowserView. WebView is explicitly out — no autofill, no SMS-OTP integration, anti-fraud risk, and PCI-discouraged. Custom Tab / SFVC is a real browser process so we keep the PCI separation + cookie/autofill/password-manager benefits, while the customer stays in the app's context (no jarring app-switch). Fallback to system browser is automatic when Custom Tab is unavailable. App stays on the waiting-payment screen and polls regardless.
D4 external_id = our payment_requests.id UUID One-shot lookup in webhook handler; Xendit's uniqueness check doubles as idempotency.
D5 Invoice invoice_duration mirrors payment_request_timeout_minutes Both sides expire together — no "Xendit confirms a payment we already marked expired".
D6 Env flag XENDIT_ENABLED gates the integration false in dev (skip invoice creation, keep stub flow). true in staging/prod. Maestro never hits Xendit.
D7 Keep /internal/_test/force-confirm-payment forever Maestro / manual QA need to advance past payment without Xendit. Internal listener only.
D8 Webhook URL is dashboard-set per environment (test/live) Xendit Invoice API does not accept per-request callback URL. Dev tunnel → test dashboard; prod URL → live dashboard.
D9 App-facing POST /payment-requests/:id/confirm is gated by XENDIT_ENABLED When Xendit is on, only the webhook can confirm. When off, app can self-confirm (current dev/Maestro behavior).

Architecture (revised 2026-05-23) — event-driven, product-agnostic payment service

Supersedes the lifecycle/coupling shape in Stages 17 below. Those stages still describe the original chat-only, webhook-as-confirmer design and will need rewriting once the open threads at the bottom of this section settle. The locked decisions D1D9 above still hold — this section refines how the service is shaped internally, not what payment provider we use.

Why this shape

Future products (courses, merch, subscriptions) will need to be paid for the same way chat sessions are. Rather than ship Phase 5 tightly coupled to chat and refactor later, we shape the payment layer today as if it were already a standalone microservice. Today it lives in-process as payment.service.js + in-process EventEmitter; tomorrow the same API surface and event names work unchanged across HTTP / pub-sub / network boundaries.

Renames vs. the original plan

Original Revised
payment_requests table payment_requests
*.payment_request_id FK columns *.payment_request_id
PaymentRequestStatus constant PaymentRequestStatus
requestPayment, confirmPayment, ... requestPayment, confirmPayment, expirePayment, ...
/api/client/payment-requests/... routes /api/client/payment-requests/...
/api/shared/payment/webhooks/xendit /api/shared/payment/webhooks/xendit (Xendit is payment-internal)
payment_request_timeout_minutes config payment_request_timeout_minutes
failed_delivery status (chat-specific) failed_delivery (product-agnostic)

Schema additions for product-agnosticism

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.

flowchart LR
    subgraph Triggers["Triggers (cause state transitions)"]
        XPaid[Xendit PAID webhook]
        XExp[Xendit EXPIRED webhook]
        Sweep[Background sweeper<br/>pending past expires_at]
        Stub[Dev force-confirm stub<br/>/internal/_test/force-confirm-payment]
        Cancel[Customer cancel button]
        DelFail[Product service reports<br/>delivery failed<br/>pairing: no mitra · merch: oos · ...]
    end

    PS{{Payment Service<br/>requestPayment · confirmPayment ·<br/>expirePayment · cancelPayment ·<br/>markDeliveryFailed · consumePayment}}

    subgraph Events["Events (emitted on terminal transitions)"]
        Conf((payment_request.confirmed))
        Exp((payment_request.expired))
        Canc((payment_request.cancelled))
        DelF((payment_request.delivery_failed))
    end

    subgraph Subs["Subscribers"]
        Pair[Pairing service<br/>filter productType=chat_session<br/>read productMetadata<br/>start mitra blast]
        Ops[Operator workflow<br/>refund / re-credit queue]
        Future[Future product services<br/>course, merch, subscription]
    end

    XPaid --> PS
    Stub  --> PS
    XExp  --> PS
    Sweep --> PS
    Cancel --> PS
    DelFail --> PS

    PS --> Conf
    PS --> Exp
    PS --> Canc
    PS --> DelF

    Conf --> Pair
    Conf --> Future
    DelF --> Ops
    Exp  --> Ops

Lifecycle of a payment_request

stateDiagram-v2
    [*] --> pending: requestPayment()<br/>row inserted · Xendit invoice created<br/>(no event — caller has the id)

    pending --> confirmed: confirmPayment()<br/>emits payment_request.confirmed
    pending --> expired: expirePayment()<br/>emits payment_request.expired
    pending --> cancelled: cancelPayment()<br/>emits payment_request.cancelled

    confirmed --> consumed: consumePayment()<br/>(product code on successful delivery)<br/>(no event — terminal success)
    confirmed --> failed_delivery: markDeliveryFailed()<br/>emits payment_request.delivery_failed

    consumed --> [*]
    expired --> [*]
    cancelled --> [*]
    failed_delivery --> [*]

Notes on the lifecycle:

  • No event at 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:

    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:

    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:

    process.on('SIGTERM', async () => {
      await app.close()                            // stop accepting new requests
      await new Promise(r => setTimeout(r, 8_000)) // let handlers drain
      process.exit(0)
    })
    

What this gives us:

Scenario Behavior
Happy path Event fires immediately; pairing starts in ms. Same UX as if EventEmitter were durable.
Process dies mid-emit Event lost; row state intact. Next sweeper tick (≤60s later) re-triggers. Customer waits up to ~1 min longer than normal but doesn't get stuck.
Process restart Startup reconciliation runs immediately; catches everything missed during the restart window.
Subscriber crash mid-handler DB shows "confirmed but no chat session yet"; sweeper re-triggers; subscriber's idempotency check resumes correctly.
Multi-instance Cloud Run Each webhook routed to one instance; subscriber runs there. No cross-instance coordination needed because work itself is idempotent.

What this gives up vs. the outbox pattern:

  • Worst-case latency on event loss is ~60s (sweeper tick interval). Outbox dispatches in seconds.
  • Only works for events with a corresponding DB row state. Every event we've designed (payment_request.confirmed, .expired, .cancelled, .delivery_failed) qualifies because each maps to a payment_requests.status value. An event that doesn't persist to a row state wouldn't be recoverable this way.

When to graduate to outbox (a future migration, not a Phase 5 concern):

  • We add subscribers where >30s latency on the worst case is unacceptable (real-time fraud detection, etc.)
  • We have multiple subscribers per event where per-subscriber acknowledgement matters (analytics OK to lose, pairing not)
  • We extract payment service from the monolith — at which point a persistent queue between services is natural anyway

Cost of this approach: ~80 lines total. Zero new infrastructure, zero new dependencies, zero new tables. Re-uses the sweeper pattern already in server.js.

Locked decisions (2026-05-23 discussion)

  • Event naming: resource-based — payment_request.confirmed, payment_request.expired, payment_request.cancelled, payment_request.delivery_failed, payment_request.failed. Diagrams and API surface above reflect this.
  • Universal lifecycle: lock the current pending → confirmed → consumed | expired | cancelled | failed | failed_delivery for all products for now. Revisit when a product emerges that genuinely needs additional terminal states (e.g. shipped for physical goods). Until then, every product reuses the same enum.
  • Xendit product: stay on Invoice (D1 unchanged). Briefly considered switching to Payment Sessions for richer state semantics, but: (a) the xendit-node SDK does not yet support Payment Sessions (we would need raw HTTP calls), (b) Payment Sessions does not actually have a session-level FAILED state — FAILED only fires on the underlying per-attempt payment.failure event, weakening the original case for the switch, and (c) Ramadhan has an existing app already integrated against Invoice; consistency across products lowers operator burden. The payment service is product-agnostic, so a future migration to Payment Sessions remains a one-file change inside payment.service.js if/when the SDK adds support or a product needs subscriptions.
  • Pairing trigger: server-driven. Pairing service subscribes to payment_request.confirmed and starts the mitra blast immediately when the event fires. Client app's POST /api/client/chat/request call is removed (or becomes a no-op when called for a payment that already triggered pairing). Phase 5 scope grows slightly but proves the event abstraction is load-bearing, and eliminates the race where a network drop between "customer sees confirmed" and "client calls chat/request" leaves them stranded.

Open threads to settle before rewriting Stages 17

  1. Stages 18 rewriting pass. The implementation prose still describes the original chat-coupled, route-handler-direct-calls-Xendit shape. It needs to be rewritten to incorporate: server-driven pairing subscriber, payment-service wrapping Xendit (not route handler), product-agnostic schema (product_type + product_metadata), event emission + reconciliation sweeper, renamed identifiers. D1/D5/D8 stay correct (still Invoice). Mechanical but sizeable.

Out of scope (deferred)

  • Recurring/subscription invoices (we sell discrete sessions)
  • Refunds via API (manual via Xendit dashboard for now)v
  • Disbursements / mitra payouts (Xendit auto-settles to the business account)
  • Multi-currency (IDR only)
  • Saved cards / one-click pay
  • Webhook event audit table (add later if debugging needs it)

Source-of-truth references


Build Order (8 stages)

Stage Title Outcome Blocks on
0 Account & credentials prep Test-mode keys + callback token + payment methods enabled in Xendit dashboard
1 Schema + identifier rename payment_requests table with product_type, product_metadata, xendit_* columns; status enum includes failed_delivery + failed; FK columns + cause_tag values renamed throughout the codebase Stage 0
2 Payment service: event emitter + Xendit wrapper payment.service.js exposes the public API (requestPayment, confirmPayment, ..., on); Xendit SDK called only from inside it; events emit on terminal transitions Stage 1
3 Webhook receiver POST /api/shared/payment/webhooks/xendit — token verify, route inbound Xendit callbacks to confirmPayment / expirePayment (which then emit events) Stage 2
4 Server-driven pairing subscriber Pairing service subscribes to payment_request.confirmed; starts mitra blast when productType === 'chat_session'; idempotency check inside Stage 2
5 Reconciliation sweeper + graceful shutdown Extend the existing payment expiry sweeper to also re-trigger lost subscriber work; SIGTERM trap to drain in-flight handlers; startup reconciliation pass Stage 4
6 client_app cutover Open invoice_url via Custom Tab (LaunchMode.inAppBrowserView); remove explicit POST /chat/request call; remove self-confirm call in session_closure_notifier.dart Stage 2 (backend smoke-testable without this)
7 Dev infra Tunnel guide (cloudflared/ngrok), fake-webhook helper script, .env.example block Stage 3
8 E2E + hardening Real test-mode run end-to-end; idempotency / retry / amount-mismatch / SIGTERM / reconciliation tests; checklist All prior

Stages 05 are backend-only and curl-smoke-testable. Stage 6 cuts the app over. Stages 78 are verification and polish — none of them block shipping if the test-mode E2E in Stage 8 passes.


Sequence — happy-path payment flow (server-driven, event-based)

Module interactions from "Customer taps Bayar" to "App advances to searching." The dev / Maestro flow (XENDIT_ENABLED=false) skips the Xendit branch entirely and /internal/_test/force-confirm-payment plays the role of the webhook arm — same downstream effect because the event emits from the same payment service method either way.

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)

# Phase 5 — Xendit
XENDIT_ENABLED=false                                  # flip to true in staging/prod
XENDIT_SECRET_KEY=xnd_development_xxx
XENDIT_WEBHOOK_TOKEN=xxx                              # dashboard → Settings → Callbacks
XENDIT_SUCCESS_REDIRECT_URL=https://halobestie.com/payment/success
XENDIT_FAILURE_REDIRECT_URL=https://halobestie.com/payment/failure

Redirect URLs are sent per-invoice (D8 caveat applies only to the webhook URL). They land the Custom Tab on a "you can close this" page; the app's waiting-payment screen drives the real UX via polling.


Stage 1 — Schema + identifier rename

This is the most invasive stage because it touches existing identifiers across backend + client_app. Doing it once cleanly avoids two rename passes.

1.1 Schema migration

Append to migrate.js as a new "Phase 5" block. Steps, all idempotent:

-- 1.1.1 Rename the table (if not already renamed on this DB)
DO $$
BEGIN
  IF to_regclass('payment_sessions') IS NOT NULL AND to_regclass('payment_requests') IS NULL THEN
    ALTER TABLE payment_sessions RENAME TO payment_requests;
  END IF;
END $$;

-- 1.1.2 Rename indexes that Postgres auto-named after the table
ALTER INDEX IF EXISTS idx_payment_sessions_customer        RENAME TO idx_payment_requests_customer;
ALTER INDEX IF EXISTS idx_payment_sessions_status_expires  RENAME TO idx_payment_requests_status_expires;

-- 1.1.3 Rename FK columns on dependent tables
DO $$
BEGIN
  IF EXISTS (SELECT 1 FROM information_schema.columns
             WHERE table_name='chat_sessions' AND column_name='payment_session_id') THEN
    ALTER TABLE chat_sessions       RENAME COLUMN payment_session_id TO payment_request_id;
  END IF;
  IF EXISTS (SELECT 1 FROM information_schema.columns
             WHERE table_name='session_extensions' AND column_name='payment_session_id') THEN
    ALTER TABLE session_extensions  RENAME COLUMN payment_session_id TO payment_request_id;
  END IF;
  IF EXISTS (SELECT 1 FROM information_schema.columns
             WHERE table_name='pairing_failures' AND column_name='payment_session_id') THEN
    ALTER TABLE pairing_failures    RENAME COLUMN payment_session_id TO payment_request_id;
  END IF;
END $$;

ALTER INDEX IF EXISTS idx_chat_sessions_payment  RENAME TO idx_chat_sessions_payment_request;

-- 1.1.4 Rename the config key
UPDATE app_config
   SET key='payment_request_timeout_minutes'
 WHERE key='payment_session_timeout_minutes'
   AND NOT EXISTS (SELECT 1 FROM app_config WHERE key='payment_request_timeout_minutes');

-- 1.1.5 Rewrite the status CHECK constraint:
--       - rename 'failed_pairing' value to 'failed_delivery'
--       - add 'failed' (createInvoice errored before customer paid)
ALTER TABLE payment_requests DROP CONSTRAINT IF EXISTS payment_sessions_status_check;
ALTER TABLE payment_requests DROP CONSTRAINT IF EXISTS payment_requests_status_check;
UPDATE payment_requests SET status='failed_delivery' WHERE status='failed_pairing';
ALTER TABLE payment_requests ADD CONSTRAINT payment_requests_status_check
  CHECK (status IN ('pending','confirmed','consumed','expired','abandoned','failed','failed_delivery'));

-- 1.1.6 Rewrite the cause_tag CHECK on pairing_failures
ALTER TABLE pairing_failures DROP CONSTRAINT IF EXISTS pairing_failures_cause_tag_check;
UPDATE pairing_failures SET cause_tag='payment_request_expired' WHERE cause_tag='payment_session_expired';
ALTER TABLE pairing_failures ADD CONSTRAINT pairing_failures_cause_tag_check
  CHECK (cause_tag IN (
    'no_mitra_available','all_mitras_rejected','targeted_mitra_offline','targeted_mitra_rejected',
    'targeted_mitra_timeout','payment_request_expired','customer_cancelled',
    'extension_rejected','extension_safeguard_tripped'
  ));

-- 1.1.7 New columns: product_type, product_metadata (microservice-prep), xendit_*
ALTER TABLE payment_requests
  ADD COLUMN IF NOT EXISTS product_type           TEXT NOT NULL DEFAULT 'chat_session',
  ADD COLUMN IF NOT EXISTS product_metadata       JSONB NOT NULL DEFAULT '{}'::jsonb,
  ADD COLUMN IF NOT EXISTS xendit_invoice_id      TEXT,
  ADD COLUMN IF NOT EXISTS xendit_invoice_url     TEXT,
  ADD COLUMN IF NOT EXISTS xendit_payment_method  TEXT,
  ADD COLUMN IF NOT EXISTS xendit_paid_amount     INTEGER;

-- 1.1.8 Partial unique index — webhook retries land on the same invoice_id; this
--       makes "already processed" detectable via a constraint violation.
CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_requests_xendit_invoice
  ON payment_requests(xendit_invoice_id) WHERE xendit_invoice_id IS NOT NULL;

Also update the original CREATE TABLE payment_sessions block higher up in migrate.js to use the new table name directly — that way a fresh DB skips the rename and the rename block becomes a no-op on next run.

1.2 Backfill product_metadata for existing rows

-- Stamp product_metadata for any pre-Phase-5 rows so subscribers can read it uniformly
UPDATE payment_requests
   SET product_metadata = jsonb_build_object(
     'duration_minutes',    duration_minutes,
     'mode',                mode,
     'is_extension',        is_extension,
     'targeted_mitra_id',   targeted_mitra_id
   )
 WHERE product_metadata = '{}'::jsonb AND product_type = 'chat_session';

1.3 Code renames

A mechanical pass across backend + client_app. Use the table from the Renames vs. the original plan section above as the canonical list. Where multiple identifiers collide (e.g. variable paymentSessionId and parameter payment_session_id), do them by length descending so substring shadowing doesn't happen.

Files affected (non-exhaustive — grep payment_session / PaymentSession / createPaymentSession across the repo):

  • backend/src/services/payment.service.js (function exports + internals)
  • backend/src/services/pairing-failure.service.js (cause_tag value)
  • backend/src/services/pairing.service.js (FK column reads)
  • backend/src/services/config.service.js (config key + getter name)
  • backend/src/services/chat.service.js (session-end refund path)
  • backend/src/routes/public/client.payment.routes.js (route URL prefix + function calls)
  • backend/src/routes/public/client.chat.routes.js (FK reads)
  • backend/src/routes/internal/*.routes.js (test stub references)
  • backend/test/**/*.test.js (test fixtures + assertions)
  • client_app/lib/core/payment/payment_repository.dart (URL + model field names)
  • client_app/lib/core/payment/payment_request_model.dart (or wherever the model lives)
  • client_app/lib/features/payment/*.dart (BLoC state + UI string references)
  • control_center/src/pages/PaymentRequestsPage.tsx (if it exists; otherwise skip)

After this stage, the codebase consistently uses payment_request everywhere. The following stages assume this rename is complete.


Stage 2 — Payment service: event emitter + Xendit wrapper

payment.service.js becomes the single owner of the payment domain. After this stage: no other file imports xendit-node, no other file SELECTs/UPDATEs payment_requests directly, and the public API matches the contract documented in the architecture section above.

2.1 Install Xendit SDK

cd backend && npm i xendit-node@^7

(Pin major; check npm view xendit-node version before installing.)

2.2 Env getter + boot validation

Append to config.service.js:

export const getXenditConfig = () => ({
  enabled: process.env.XENDIT_ENABLED === 'true',
  secretKey: process.env.XENDIT_SECRET_KEY ?? '',
  webhookToken: process.env.XENDIT_WEBHOOK_TOKEN ?? '',
  successRedirectUrl: process.env.XENDIT_SUCCESS_REDIRECT_URL ?? '',
  failureRedirectUrl: process.env.XENDIT_FAILURE_REDIRECT_URL ?? '',
})

Boot validation in server.js, before initFirebase():

const xc = getXenditConfig()
if (xc.enabled) {
  if (!xc.secretKey) throw new Error('XENDIT_ENABLED=true requires XENDIT_SECRET_KEY')
  if (!xc.webhookToken || xc.webhookToken.length < 16) {
    throw new Error('XENDIT_ENABLED=true requires XENDIT_WEBHOOK_TOKEN (>= 16 chars)')
  }
}

2.3 EventEmitter inside payment.service.js

import { EventEmitter } from 'node:events'

const emitter = new EventEmitter()
// Allow more listeners than the default 10 in case multiple products subscribe to the same event
emitter.setMaxListeners(50)

// Public API: subscribers call this at app startup
export const on = (eventName, handler) => {
  emitter.on(eventName, (payload) => {
    // Wrap every handler so a throwing subscriber doesn't crash the process
    // and so handlers run as fire-and-forget (don't block emit() returning)
    Promise.resolve()
      .then(() => handler(payload))
      .catch((err) => console.error(`[payment event ${eventName}] handler failed`, err))
  })
}

// Internal — called by the state-transition functions below
const emit = (eventName, payload) => emitter.emit(eventName, payload)

Events fire from inside confirmPayment / expirePayment / cancelPayment / markDeliveryFailed AFTER the DB commit succeeds. Event payload shape:

{
  paymentRequestId: string,    // UUID
  productType: string,         // 'chat_session', etc
  productMetadata: object,     // the JSONB blob — whatever the product stamped
  amount: number,              // IDR rupiah
  customerId: string,          // UUID
  // confirmed-only:
  xenditInvoiceId?: string,
  xenditPaymentMethod?: string,
}

2.4 Public API rewrite

Rewrite payment.service.js so the exports exactly match the contract:

// PUBLIC — these are the only functions other code may import.

export const requestPayment = async ({
  productType,
  productMetadata,
  customerId,
  amount,
  ttlMinutes,
  // chat-specific carry-throughs that stay top-level for now (legacy schema):
  durationMinutes,
  mode,
  isFirstSessionDiscount,
  isExtension,
  targetedMitraId,
}) => {
  // 1. INSERT row (status=pending)
  const row = await sql`
    INSERT INTO payment_requests (
      customer_id, amount, duration_minutes, mode,
      is_first_session_discount, is_extension, targeted_mitra_id,
      product_type, product_metadata, status, expires_at
    ) VALUES (
      ${customerId}, ${amount}, ${durationMinutes}, ${mode},
      ${isFirstSessionDiscount}, ${isExtension}, ${targetedMitraId},
      ${productType}, ${sql.json(productMetadata)},
      ${PaymentRequestStatus.PENDING},
      NOW() + (${ttlMinutes} || ' minutes')::interval
    )
    RETURNING *
  `.then(r => r[0])

  // 2. If Xendit on, create the invoice + stamp the row
  const xc = getXenditConfig()
  if (xc.enabled) {
    try {
      const { invoiceId, invoiceUrl } = await createXenditInvoice({
        paymentRequestId: row.id,
        amount: row.amount,
        ttlMinutes,
        description: buildDescriptionFor(row),
      })
      await sql`
        UPDATE payment_requests
        SET xendit_invoice_id = ${invoiceId}, xendit_invoice_url = ${invoiceUrl}
        WHERE id = ${row.id}
      `
      row.xendit_invoice_id = invoiceId
      row.xendit_invoice_url = invoiceUrl
    } catch (err) {
      // Distinct terminal state — "we never got an invoice." NOT expired.
      await sql`
        UPDATE payment_requests
        SET status = ${PaymentRequestStatus.FAILED}
        WHERE id = ${row.id} AND status = ${PaymentRequestStatus.PENDING}
      `
      emit('payment_request.failed', toEventPayload(row))
      throw Object.assign(new Error('Payment provider error'), {
        code: 'PAYMENT_PROVIDER_ERROR',
        statusCode: 502,
        cause: err,
      })
    }
  }

  return row
}

export const confirmPayment = async (paymentRequestId, xenditMeta = {}) => {
  // State transition pending → confirmed (idempotency: throws INVALID_STATE if not pending)
  const row = await transitionPending(paymentRequestId, PaymentRequestStatus.CONFIRMED, { confirmed_at: 'NOW()' })
  if (xenditMeta.invoiceId || xenditMeta.paymentMethod || xenditMeta.amount) {
    await sql`
      UPDATE payment_requests
      SET xendit_invoice_id = COALESCE(${xenditMeta.invoiceId ?? null}, xendit_invoice_id),
          xendit_payment_method = ${xenditMeta.paymentMethod ?? null},
          xendit_paid_amount = ${xenditMeta.amount ?? null}
      WHERE id = ${paymentRequestId}
    `
  }
  emit('payment_request.confirmed', toEventPayload(row))
  return row
}

export const expirePayment = async (paymentRequestId) => {
  const row = await transitionPending(paymentRequestId, PaymentRequestStatus.EXPIRED)
  if (row) emit('payment_request.expired', toEventPayload(row))
  return row
}

export const cancelPayment = async (paymentRequestId, customerId) => {
  // Customer-initiated; check ownership
  const row = await transitionPendingWithOwner(paymentRequestId, customerId, PaymentRequestStatus.ABANDONED)
  emit('payment_request.cancelled', toEventPayload(row))
  return row
}

export const markDeliveryFailed = async (paymentRequestId, causeTag) => {
  // Confirmed → failed_delivery; called by product code (pairing, future merch service)
  const row = await transitionConfirmed(paymentRequestId, PaymentRequestStatus.FAILED_DELIVERY)
  await recordFailure({ paymentRequestId, causeTag, ... })
  emit('payment_request.delivery_failed', { ...toEventPayload(row), causeTag })
  return row
}

export const consumePayment = async (paymentRequestId) => {
  // Confirmed → consumed; called by product code when delivery succeeds
  // No event — terminal success, nothing else needs to react
  return transitionConfirmed(paymentRequestId, PaymentRequestStatus.CONSUMED, { consumed_at: 'NOW()' })
}

export const getPayment = async (id) => {
  const [row] = await sql`SELECT * FROM payment_requests WHERE id = ${id}`
  return row ?? null
}

export { on }   // event subscription

2.5 Internal Xendit wrapper

Private helpers inside payment.service.js (do NOT export):

import { Xendit } from 'xendit-node'

let _client = null
const xenditClient = () => {
  if (_client) return _client
  _client = new Xendit({ secretKey: getXenditConfig().secretKey })
  return _client
}

const createXenditInvoice = async ({ paymentRequestId, amount, ttlMinutes, description }) => {
  const inv = await xenditClient().Invoice.createInvoice({
    data: {
      externalId: paymentRequestId,
      amount,
      description,
      invoiceDuration: Math.floor(ttlMinutes * 60),
      currency: 'IDR',
      successRedirectUrl: getXenditConfig().successRedirectUrl || undefined,
      failureRedirectUrl: getXenditConfig().failureRedirectUrl || undefined,
      // paymentMethods: null → honor dashboard config
    },
  })
  return { invoiceId: inv.id, invoiceUrl: inv.invoiceUrl }
}

export const verifyWebhookToken = (headerToken) => {
  const { webhookToken } = getXenditConfig()
  if (!headerToken || !webhookToken) return false
  if (typeof headerToken !== 'string') return false
  if (headerToken.length !== webhookToken.length) return false
  let mismatch = 0
  for (let i = 0; i < headerToken.length; i++) {
    mismatch |= headerToken.charCodeAt(i) ^ webhookToken.charCodeAt(i)
  }
  return mismatch === 0
}

verifyWebhookToken is exported because the webhook route (Stage 3) calls it. It's the one Xendit-aware helper the route layer is allowed to use.

2.6 Gate POST /payment-requests/:id/confirm on XENDIT_ENABLED

In client.payment.routes.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:

import { verifyWebhookToken, confirmPayment, expirePayment, getPayment } from '../../services/payment.service.js'

export const paymentWebhookRoutes = async (app) => {
  app.post('/webhooks/xendit', async (request, reply) => {
    const token = request.headers['x-callback-token']
    if (!verifyWebhookToken(token)) {
      request.log.warn('xendit webhook: bad token')
      return reply.code(401).send({ error: 'invalid_token' })
    }

    const { id: invoiceId, external_id: paymentRequestId, status, amount, payment_method } = request.body ?? {}
    request.log.info({ paymentRequestId, invoiceId, status, amount }, 'xendit webhook received')

    if (!paymentRequestId) {
      return reply.send({ ok: true, ignored: 'no_external_id' })   // forward-compat
    }

    const existing = await getPayment(paymentRequestId)
    if (!existing) {
      request.log.warn({ paymentRequestId, invoiceId }, 'unknown payment_request — ACKing so Xendit stops retrying')
      return reply.send({ ok: true, ignored: 'unknown_payment_request' })
    }

    if (status === 'PAID') {
      if (typeof amount === 'number' && amount !== existing.amount) {
        request.log.error({ paymentRequestId, expected: existing.amount, got: amount }, 'amount mismatch')
        return reply.code(409).send({ error: 'amount_mismatch' })
      }
      try {
        await confirmPayment(paymentRequestId, { invoiceId, paymentMethod: payment_method, amount })
      } catch (err) {
        if (err.code === 'INVALID_STATE' || err.code === 'CONFLICT') {
          request.log.info({ paymentRequestId, code: err.code, prevStatus: existing.status }, 'already terminal, ACKing')
        } else if (err.code === 'EXPIRED') {
          request.log.error({ paymentRequestId, expiredAt: existing.expires_at }, 'PAID after expiry — manual recovery needed')
        } else {
          throw err
        }
      }
      return reply.send({ ok: true })
    }

    if (status === 'EXPIRED') {
      await expirePayment(paymentRequestId)
      return reply.send({ ok: true })
    }

    return reply.send({ ok: true, ignored: status })   // forward-compat for future Xendit event types
  })
}

3.2 Register the route

In app.public.js:

import { paymentWebhookRoutes } from './routes/public/shared.payment-webhooks.routes.js'
// ...
app.register(paymentWebhookRoutes, { prefix: '/api/shared/payment' })

Final URL: POST /api/shared/payment/webhooks/xendit. The path signals that Xendit is a specific provider behind the payment service — a future Midtrans webhook would land at /api/shared/payment/webhooks/midtrans.

3.3 Test coverage

Vitest covering: bad token (401), happy PAID (200 + row confirmed + xendit cols stamped), amount mismatch (409), retry idempotency (second PAID with same invoice → 200 ACK, row stays confirmed), EXPIRED, unknown payment_request (200 ignored), unhandled status (200 ignored). See test/routes/shared.payment-webhooks.routes.test.js.


Stage 4 — Server-driven pairing subscriber

4.1 Restructure pairing entry point

Pull the existing chat-blast logic into a function that takes only the payment row + its metadata. In pairing.service.js:

export const startPairingFromPaymentRequest = async ({ paymentRequestId, productMetadata, customerId }) => {
  // Idempotency — safe re-run from reconciliation sweeper or webhook retry
  const [existing] = await sql`
    SELECT id FROM chat_sessions WHERE payment_request_id = ${paymentRequestId}
  `
  if (existing) return existing

  // Build chat_sessions row using product_metadata (no longer reading from payment_requests's
  // legacy chat-specific columns — eventually those move into product_metadata too)
  const { duration_minutes, mode, targeted_mitra_id, is_extension } = productMetadata

  const [chatSession] = await sql`
    INSERT INTO chat_sessions (
      customer_id, status, duration_minutes, mode,
      payment_request_id, is_first_session_discount
    ) VALUES (
      ${customerId}, 'searching', ${duration_minutes}, ${mode},
      ${paymentRequestId}, ...
    )
    RETURNING *
  `

  // Existing blast logic (Phase 2)
  await blastToAvailableMitras({ chatSessionId: chatSession.id, targetedMitraId: targeted_mitra_id })

  return chatSession
}

4.2 Register the subscriber at app startup

In server.js, after building both apps and before listen() (or in a dedicated subscriber-registration module called from there):

import * as payment from './services/payment.service.js'
import { startPairingFromPaymentRequest } from './services/pairing.service.js'

payment.on('payment_request.confirmed', async (evt) => {
  if (evt.productType !== 'chat_session') return    // future merch/course handlers filter their own
  await startPairingFromPaymentRequest({
    paymentRequestId: evt.paymentRequestId,
    productMetadata: evt.productMetadata,
    customerId: evt.customerId,
  })
})

4.3 Remove the explicit chat-request call (backend side)

POST /api/client/chat/request becomes a no-op when the payment row already has a chat_session — so legacy clients (if any) calling it get a 200 with the existing chat session id. After client_app cutover (Stage 6) the endpoint can be removed entirely (follow-up phase, not Phase 5 scope).

4.4 Confirm extension flow

The extension flow (Lanjut Curhat) also creates a payment_request; same subscriber handles it via a productMetadata flag (is_extension: true). The handler branches to the extension logic instead of starting a fresh blast. Verify the existing extension service code path is callable from this subscriber.


Stage 5 — Reconciliation sweeper + graceful shutdown

5.1 Extend the existing payment sweeper

In payment.service.js, update expireStalePaymentRequests (renamed from expireStalePaymentSessions) to also dispatch lost subscriber work:

export const expireStalePaymentRequests = async () => {
  // ... existing expire pending past expires_at logic ...
  // ... existing confirmed-but-stale → failed_delivery logic ...

  // NEW: reconciliation — confirmed payments that never started their product work
  const orphaned = await sql`
    SELECT id, customer_id, product_type, product_metadata, amount
    FROM payment_requests
    WHERE status = 'confirmed'
      AND confirmed_at < NOW() - INTERVAL '30 seconds'
      AND product_type = 'chat_session'
      AND NOT EXISTS (SELECT 1 FROM chat_sessions cs WHERE cs.payment_request_id = payment_requests.id)
      AND NOT EXISTS (SELECT 1 FROM pairing_failures pf WHERE pf.payment_request_id = payment_requests.id)
    LIMIT 100   -- bound per-tick work
  `

  for (const row of orphaned) {
    try {
      await startPairingFromPaymentRequest({
        paymentRequestId: row.id,
        productMetadata: row.product_metadata,
        customerId: row.customer_id,
      })
      console.log(`[reconciler] re-triggered pairing for ${row.id}`)
    } catch (err) {
      console.error(`[reconciler] failed to re-trigger ${row.id}`, err)
    }
  }
  return { ...existingCounts, reconciled: orphaned.length }
}

The 30-second buffer avoids racing with happy-path subscribers that are mid-flight.

5.2 Run reconciliation at startup

In server.js, after restoreActiveTimers():

// Catch anything missed during a restart window
await expireStalePaymentRequests().catch(err => console.error('[reconciler] startup pass failed', err))

5.3 SIGTERM trap

In server.js, before start().catch(...):

const shutdown = async () => {
  console.log('SIGTERM received — closing servers, draining handlers')
  await Promise.allSettled([publicApp.close(), internalApp.close()])
  // Give EventEmitter handlers up to 8s to finish (Cloud Run grace ~10s)
  await new Promise(r => setTimeout(r, 8_000))
  process.exit(0)
}
process.on('SIGTERM', shutdown)
process.on('SIGINT',  shutdown)

Stage 6 — client_app cutover

6.1 Read invoice_url from response

In payment_repository.dart, parse data.invoice_url from the POST /payment-requests response and add it to the payment-request model.

6.2 Open Custom Tab on receipt

In the screen that immediately follows payment creation (waiting-payment screen):

import 'package:url_launcher/url_launcher.dart';

if (paymentRequest.invoiceUrl != null) {
  await launchUrl(
    Uri.parse(paymentRequest.invoiceUrl!),
    mode: LaunchMode.inAppBrowserView,   // Custom Tab on Android, SFVC on iOS
  );
}
// Either way (Xendit live or stub), show waiting-payment UI and poll.

When invoice_url is null (dev / XENDIT_ENABLED=false), the screen sits exactly as it does today — the force-confirm-payment stub flips the row and polling picks it up.

Verify that url_launcher is already a dependency (it is, used elsewhere in the app).

6.3 Remove the explicit chat-request call

After polling sees status=confirmed, the app should NOT call POST /chat/request anymore — the server-side subscriber has already started pairing. Instead:

  • Find the call in the post-confirmed flow (currently in PaymentBloc or equivalent)
  • Replace with a poll on the chat-session status (extend the existing polling to also return chat_session_id when available, or add a separate endpoint)

6.4 Remove the self-confirm call

In session_closure_notifier.dart:75 the app currently calls POST /payment-requests/:id/confirm during chat-closure rebill. With Xendit on, this returns 403. Remove the call; rely on polling.

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):

    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:

    ngrok http 3000
    

Register the tunnel URL in Xendit Test Mode → Settings → Callbacks → Invoice Paid / Invoice Expired:

https://<tunnel>/api/shared/payment/webhooks/xendit

Document this in backend/CLAUDE.md under a new "Phase 5 dev setup" section so the next person doesn't have to re-derive it.

7.2 Fake-webhook helper script

backend/.dev/xendit-fake-webhook.sh:

#!/usr/bin/env bash
# Fire a fake Xendit Invoice callback at the local backend.
# Usage: ./xendit-fake-webhook.sh <payment_request_id> [PAID|EXPIRED] [amount]
set -euo pipefail
PAYMENT_ID="${1:?usage}"
STATUS="${2:-PAID}"
AMOUNT="${3:-50000}"
TOKEN="${XENDIT_WEBHOOK_TOKEN:?XENDIT_WEBHOOK_TOKEN env not set}"
BASE_URL="${BASE_URL:-http://localhost:3000}"

curl -sS -X POST "${BASE_URL}/api/shared/payment/webhooks/xendit" \
  -H "x-callback-token: ${TOKEN}" \
  -H "content-type: application/json" \
  -d "{
    \"id\": \"inv_fake_$(date +%s)_${RANDOM}\",
    \"external_id\": \"${PAYMENT_ID}\",
    \"status\": \"${STATUS}\",
    \"amount\": ${AMOUNT},
    \"payment_method\": \"BCA\"
  }" | jq . 2>/dev/null || cat
echo

Lets developers exercise the webhook handler without ngrok / Xendit. NOT a Maestro replacement — Maestro continues to use /internal/_test/force-confirm-payment.

7.3 .env.example block

Append to backend/.env.example:

# Phase 5 — Xendit (dev-safe defaults: integration disabled)
XENDIT_ENABLED=false
XENDIT_SECRET_KEY=
XENDIT_WEBHOOK_TOKEN=
XENDIT_SUCCESS_REDIRECT_URL=
XENDIT_FAILURE_REDIRECT_URL=

Stage 8 — E2E verification + hardening checklist

8.1 End-to-end smoke test (run once before declaring Phase 5 done)

  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

  1. Retry idempotency: click Simulate Payment twice → second webhook → ACKs without second-confirm error; row stays confirmed; only one chat_session exists.
  2. Expiry: create a request, let invoice_duration elapse → Xendit fires EXPIRED → row goes expired; payment_request.expired event fires (visible in logs).
  3. Amount tamper: edit DB row to set amount=1; simulate PAID with amount=50000 → webhook returns 409; row stays unconfirmed.
  4. 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.
  5. 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.
  6. 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 template.

8.3 Pre-ship hardening checklist

  1. Webhook ACKs in <2s. Xendit retries on 5xx / timeouts (>30s). Confirm by tailing logs during a Simulate Payment burst.
  2. Idempotent confirm under race. Two webhook deliveries within 100ms → second one hits INVALID_STATE → swallowed → ACK. Unit test in test/services/payment.service.test.js.
  3. Unknown payment_request webhook. ACK with {ignored: 'unknown_payment_request'} so Xendit stops retrying orphan webhooks. Already in handler.
  4. Webhook ordering — PAID before our row knows its xendit_invoice_id. QRIS can confirm in <500ms. Handler looks up the row by external_id (= our UUID), not by xendit_invoice_id, so order doesn't matter. The stamping UPDATE inside confirmPayment uses the invoice id from the webhook body.
  5. PAID arriving after expiry sweeper. D5 (invoice_duration === payment_request_timeout_minutes) keeps these aligned. The handler's EXPIRED error catch logs at error level so we notice if it ever fires in production.
  6. Boot-time validation. Already in Stage 2.2. Tested by setting XENDIT_ENABLED=true with empty XENDIT_SECRET_KEY and asserting boot fails.
  7. Log volume in prod. Webhook entries at info; ignored/unknown at warn; mismatch / EXPIRED-after-confirm at error. The high-volume health-check path stays at info.
  8. Refund process documented. When payment_request.delivery_failed fires, operator sees the row in CC, manually refunds via Xendit dashboard, marks operator_action='refunded' in CC. No automated refund in Phase 5 — doc only.

Effort estimate

Stage Effort Notes
0 30 min Ramadhan, account + dashboard setup
1 34 hrs Migration + identifier rename across backend + client_app + tests
2 34 hrs Payment service rewrite (event emitter + Xendit wrap + state-transition refactor + tests)
3 12 hrs Webhook receiver + tests
4 12 hrs Pairing subscriber + entry-point refactor
5 1 hr Sweeper extension + SIGTERM + startup pass
6 12 hrs client_app cutover (Custom Tab + remove chat-request + remove self-confirm)
7 1 hr Tunnel setup + helper script + .env.example
8 23 hrs Real E2E + 6 failure-path tests + hardening checklist

Total: ~22.5 days of focused work. Stage 1 is the biggest because the rename touches the most files. Stages 25 are the architectural core. Stages 68 are the user-facing + ops polish.