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>
1327 lines
58 KiB
Markdown
1327 lines
58 KiB
Markdown
# 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.
|