Customer end-of-session (figma §6):
- PricingBottomSheet: ghost "cukup, akhiri sesi" CTA + dedup divider
- chat_screen._runEndSessionFlow chains ConfirmEndStep1 → ConfirmEndStep2
→ ClosingMessageSheet (or "lewati saja" → close + /home). The four
popup/sheet widgets already existed; this commit just wires them
- showModalBottomSheet: showDragHandle=false to suppress the Material 3
auto-injected handle that was stacking with our own pill
Notification sound on API 33+:
- Bump channel halobestie_chat_v1 → halobestie_chat_v2, created from
native Kotlin in MainActivity.kt with AudioAttributes contentType
CONTENT_TYPE_SONIFICATION. flutter_local_notifications' default of
CONTENT_TYPE_UNKNOWN was causing Android 13 to silently drop audio
focus while the notification still posted (isNoisy=true). Both apps
- Backend FCM payload channelId updated to v2
- AndroidManifest meta-data: default_notification_icon + color → brand
silhouette tinted pink instead of generic Android bell. Both apps
Customer pairing reliability:
- pairing_notifier: applyPairedFromPush({sessionId, mitraName}) unsticks
searching screen when WS push failed and FCM/active-session-poll is
the first signal. Idempotent across PairingSearchingData,
PairingTargetedWaitingData, PairingErrorData (covers ALREADY_ACTIVE)
- notification_service: dispatches every FCM data payload to an
onDataMessage callback (foreground + tap + cold-start). main.dart
wires that to applyPairedFromPush on type=='paired'. Foreground
'paired' no longer renders a local banner — screen self-advances
- main.dart activeSession listener also calls applyPairedFromPush when
a session appears server-side while pairing is in a waiting state.
Covers stale ALREADY_ACTIVE recovery without a full page refresh
Auth refresh token race:
- auth_notifier._refreshFromStorage shares a single in-flight Future
across all callers (Auth.build + 401-retry path). Backend rotates
refresh tokens, so concurrent callers using the same stored token
would race → loser 401s → catch wipes flutter_secure_storage → user
appears logged out after kill+reopen
Polish:
- method_pick_screen: resizeToAvoidBottomInset=false — prevents the
one-frame overflow when entering with the previous screen's keyboard
still animating out
- bestie_history: BestieHistoryItem now carries `status` (backend
already returns it). Removed _rawHistoryProvider that fetched the
same endpoint just to read status; the two providers could go out
of sync mid-rebuild and throw RangeError(length) on indexing
Xendit Stage 8 (carried from WIP):
- xendit_checkout_screen: embedded webview hosting Xendit's invoice
page (intercepts halobestie:// deeplink + return-page URLs for
deterministic pop)
- waiting_payment_screen: auto-pushes the webview when the backend
payload includes xendit_invoice_url; spinner card + "Buka ulang
halaman pembayaran" CTA for the QR-fallback path
- pubspec: webview_flutter ^4.13.0
Maestro infra:
- subflows/onboarding_returning_user: drop the "Mulai" carousel wait
(splash auto-advances since 2026-05-26); tap phone-field hint
instead of point; drop hideKeyboard (sends BACK → /home when the
IME isn't actually up)
- New flow ts-customer-06-01-end_session_via_timeup_sheet: drives
the full path to the chat-expired banner. Last step blocked by a
Maestro+Flutter gesture quirk on the perpanjang ElevatedButton
(raw `adb input tap` works at the same coords). Documented in
memory; deeplink fixture or manual verify recommended
- ChatExpiredBanner button wrapped with Semantics(identifier:
'chat_extend_button', button: true, onTap: …) — good hygiene for
future tests even though it doesn't fix the dadb tap issue
.dev/: tracked wsl_emulator_bridge.ps1 + wsl_tcp_relay.py for
Maestro-on-WSL setup (Windows-side netsh portproxy + WSL-side
loopback relays). Both referenced from existing CLAUDE.md notes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
699 lines
28 KiB
JavaScript
699 lines
28 KiB
JavaScript
// Phase 5: Payment service — single owner of the payment_requests table + Xendit integration.
|
|
//
|
|
// Public API surface (the future microservice contract):
|
|
//
|
|
// requestPayment({ productType, productMetadata, customerId, amount, ttlMinutes, ... })
|
|
// → inserts row, optionally creates Xendit invoice, returns row (with invoice_url if Xendit on)
|
|
//
|
|
// confirmPayment(paymentRequestId, xenditMeta = {})
|
|
// → pending → confirmed; emits 'payment_request.confirmed'
|
|
//
|
|
// expirePayment(paymentRequestId)
|
|
// → pending → expired (webhook-callable, no customer check); emits 'payment_request.expired'
|
|
//
|
|
// cancelPayment(paymentRequestId, customerId)
|
|
// → customer-initiated pending → abandoned; emits 'payment_request.cancelled'
|
|
//
|
|
// markDeliveryFailed(paymentRequestId, causeTag)
|
|
// → confirmed → failed_delivery; writes pairing_failures row; emits 'payment_request.delivery_failed'
|
|
//
|
|
// consumePayment(paymentRequestId)
|
|
// → confirmed → consumed; no event (terminal success)
|
|
//
|
|
// getPayment(id) → row or null
|
|
// getCustomerPendingPayments(customerId) → { items, total }
|
|
// expireStalePaymentRequests() → background sweeper + reconciliation
|
|
//
|
|
// on(eventName, handler) → subscribe to lifecycle events
|
|
// verifyWebhookToken(headerToken) → constant-time compare for webhook auth (used by route)
|
|
// xenditInvoiceMethodFromCode(payment_code) → catalog→Xendit invoice filter map (exported for tests)
|
|
//
|
|
// registerPairingSubscriber() → wires pairing.service as a subscriber to payment_request.confirmed
|
|
// recordIntermediateFailure(...) → audit-only failure for flows with a fallback path
|
|
//
|
|
// Internals (NOT exported):
|
|
// createXenditInvoice() — wraps xendit-node SDK
|
|
// emit() / emitter — EventEmitter setup
|
|
//
|
|
// Events emit AFTER the DB transition commits. Subscribers run on the next tick
|
|
// (handlers wrapped fire-and-forget) so the publisher is never blocked.
|
|
//
|
|
// Durability story: events are in-process EventEmitter; lost on process death.
|
|
// The reconciliation sweeper (expireStalePaymentRequests) re-derives missed work
|
|
// from DB state every minute + on startup. Subscribers MUST be idempotent.
|
|
// See requirement/phase5-xendit-plan.md "Event durability" section.
|
|
|
|
import { EventEmitter } from 'node:events'
|
|
import { Xendit } from 'xendit-node'
|
|
import { getDb } from '../db/client.js'
|
|
import { PaymentRequestStatus, PairingFailureCause, UserType, WsMessage, SessionMode } from '../constants.js'
|
|
import { recordFailure } from './pairing-failure.service.js'
|
|
import { sendToUser } from '../plugins/websocket.js'
|
|
import { sendPushNotification } from './notification.service.js'
|
|
import {
|
|
getPaymentRequestTimeoutMinutes as readPaymentRequestTimeoutMinutes,
|
|
getXenditConfig,
|
|
} from './config.service.js'
|
|
|
|
const sql = getDb()
|
|
|
|
// --- EventEmitter setup ---
|
|
|
|
const emitter = new EventEmitter()
|
|
// Bump default 10-listener cap so future product subscribers don't trigger the leak warning
|
|
emitter.setMaxListeners(50)
|
|
|
|
export const on = (eventName, handler) => {
|
|
emitter.on(eventName, (payload) => {
|
|
// Wrap every handler so an unhandled throw doesn't kill the process AND so handlers
|
|
// run async-fire-and-forget (don't block the publisher's emit() return). Errors are
|
|
// logged; recovery is the sweeper's job.
|
|
Promise.resolve()
|
|
.then(() => handler(payload))
|
|
.catch((err) => console.error(`[payment event ${eventName}] handler failed`, err))
|
|
})
|
|
}
|
|
|
|
const emit = (eventName, payload) => emitter.emit(eventName, payload)
|
|
|
|
// --- Internal Xendit client (lazy + re-creatable for test env stubbing) ---
|
|
|
|
let _xenditClient = null
|
|
let _xenditKey = null
|
|
const xenditClient = () => {
|
|
const { secretKey } = getXenditConfig()
|
|
if (_xenditClient && _xenditKey === secretKey) return _xenditClient
|
|
_xenditClient = new Xendit({ secretKey })
|
|
_xenditKey = secretKey
|
|
return _xenditClient
|
|
}
|
|
|
|
// Map our catalog `payment_code` (Xendit channel-code style — `BCA_VIRTUAL_ACCOUNT`,
|
|
// `CARDS`, etc., per https://docs.xendit.co/docs/available-payment-channels) to the
|
|
// short token Xendit's Create-Invoice `paymentMethods[]` filter expects. The two
|
|
// vocabularies overlap a lot but are not identical — channel codes are descriptive
|
|
// (`BCA_VIRTUAL_ACCOUNT`), invoice filter values are by-payment-rail (`BCA`).
|
|
//
|
|
// Returning null means "no mapping" — caller should omit `paymentMethods` so Xendit
|
|
// shows the full multi-method picker (graceful degradation). Avoid throwing here —
|
|
// a stray unknown code shouldn't break invoice creation.
|
|
//
|
|
// Reference list (Xendit Create Invoice `payment_methods` accepted values, May 2026):
|
|
// Bank: BCA, BNI, BRI, BSI, BJB, MANDIRI, PERMATA, SAHABAT_SAMPOERNA
|
|
// Retail: ALFAMART, INDOMARET
|
|
// E-wallet: OVO, DANA, SHOPEEPAY, LINKAJA, JENIUSPAY, ASTRAPAY
|
|
// QR: QRIS Card: CREDIT_CARD Paylater: KREDIVO, AKULAKU, UANGME, ATOME
|
|
//
|
|
// Notes:
|
|
// - CIMB is NOT exposed by Xendit's invoice paymentMethods filter (only as a raw VA
|
|
// channel via the lower-level VA API), so CIMB_VIRTUAL_ACCOUNT intentionally
|
|
// returns null → multi-method page fallback.
|
|
export const xenditInvoiceMethodFromCode = (code) => {
|
|
if (!code || typeof code !== 'string') return null
|
|
const m = {
|
|
QRIS: 'QRIS',
|
|
OVO: 'OVO',
|
|
DANA: 'DANA',
|
|
SHOPEEPAY: 'SHOPEEPAY',
|
|
LINKAJA: 'LINKAJA',
|
|
ASTRAPAY: 'ASTRAPAY',
|
|
BCA_VIRTUAL_ACCOUNT: 'BCA',
|
|
BNI_VIRTUAL_ACCOUNT: 'BNI',
|
|
BRI_VIRTUAL_ACCOUNT: 'BRI',
|
|
BSI_VIRTUAL_ACCOUNT: 'BSI',
|
|
MANDIRI_VIRTUAL_ACCOUNT: 'MANDIRI',
|
|
PERMATA_VIRTUAL_ACCOUNT: 'PERMATA',
|
|
BJB_VIRTUAL_ACCOUNT: 'BJB',
|
|
BSS_VIRTUAL_ACCOUNT: 'SAHABAT_SAMPOERNA',
|
|
ALFAMART: 'ALFAMART',
|
|
INDOMARET: 'INDOMARET',
|
|
CARDS: 'CREDIT_CARD',
|
|
}
|
|
return m[code.toUpperCase()] ?? null
|
|
}
|
|
|
|
const createXenditInvoice = async ({ paymentRequestId, amount, ttlMinutes, description, preferredPaymentCode }) => {
|
|
const { successRedirectUrl, failureRedirectUrl } = getXenditConfig()
|
|
const invoiceMethod = xenditInvoiceMethodFromCode(preferredPaymentCode)
|
|
console.log('[xendit] createInvoice', { paymentRequestId, amount, preferredPaymentCode, invoiceMethod })
|
|
const inv = await xenditClient().Invoice.createInvoice({
|
|
data: {
|
|
externalId: paymentRequestId, // D4 — our UUID is the Xendit external_id
|
|
amount,
|
|
description,
|
|
invoiceDuration: Math.floor(ttlMinutes * 60), // D5 — TTL mirrors session timeout
|
|
currency: 'IDR',
|
|
successRedirectUrl: successRedirectUrl || undefined,
|
|
failureRedirectUrl: failureRedirectUrl || undefined,
|
|
// Stamped so a shared webhook router (no DB access) can route v1 vs v2 traffic
|
|
// purely from the echoed payload. Keep this string stable — it is a routing key.
|
|
metadata: { app: 'halobestie_v2' },
|
|
// Lock the hosted page to the customer's chosen method when we have a mapping.
|
|
// When mapping returns null (unknown / unsupported code), omit the filter and
|
|
// let Xendit show the full picker so the customer can still pay.
|
|
...(invoiceMethod ? { paymentMethods: [invoiceMethod] } : {}),
|
|
},
|
|
})
|
|
return { invoiceId: inv.id, invoiceUrl: inv.invoiceUrl }
|
|
}
|
|
|
|
// Used by the webhook route to authenticate Xendit's x-callback-token header.
|
|
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
|
|
}
|
|
|
|
// Test-only — drop cached client so vi.stubEnv changes take effect.
|
|
export const _resetXenditClientForTest = () => {
|
|
_xenditClient = null
|
|
_xenditKey = null
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
const getPaymentRequestTimeoutMinutes = async () => {
|
|
const { payment_request_timeout_minutes } = await readPaymentRequestTimeoutMinutes()
|
|
return payment_request_timeout_minutes
|
|
}
|
|
|
|
const buildEventPayload = (row) => ({
|
|
paymentRequestId: row.id,
|
|
productType: row.product_type ?? 'chat_session',
|
|
productMetadata: row.product_metadata ?? {},
|
|
customerId: row.customer_id,
|
|
amount: row.amount,
|
|
xenditInvoiceId: row.xendit_invoice_id ?? null,
|
|
xenditPaymentMethod: row.xendit_payment_method ?? null,
|
|
})
|
|
|
|
const buildInvoiceDescription = (row) => {
|
|
if (row.product_type === 'chat_session') {
|
|
return row.is_extension
|
|
? `Perpanjangan sesi ${row.duration_minutes} menit`
|
|
: `Sesi ${row.duration_minutes} menit`
|
|
}
|
|
// Generic fallback — future products can build their own descriptions and pass via productMetadata
|
|
return row.product_metadata?.description ?? `Pembayaran ${row.product_type}`
|
|
}
|
|
|
|
// --- requestPayment: insert pending row + (if Xendit on) mint invoice ---
|
|
|
|
/**
|
|
* Create a new payment request in `pending` status. When XENDIT_ENABLED=true, also
|
|
* creates a Xendit Invoice and stamps invoice_id + invoice_url on the row.
|
|
*
|
|
* Product-agnostic: callers stamp `productType` + `productMetadata`. The legacy
|
|
* top-level chat-specific args (durationMinutes, mode, isExtension, targetedMitraId,
|
|
* isFirstSessionDiscount) are accepted for backward compat with existing chat code,
|
|
* and also written into product_metadata when productType === 'chat_session'.
|
|
*/
|
|
export const requestPayment = async ({
|
|
productType = 'chat_session',
|
|
productMetadata = {},
|
|
customerId,
|
|
amount,
|
|
ttlMinutes,
|
|
// Chat-specific legacy fields (still written to top-level columns for now)
|
|
durationMinutes,
|
|
mode = SessionMode.CHAT,
|
|
isFirstSessionDiscount = false,
|
|
isExtension = false,
|
|
targetedMitraId = null,
|
|
// Customer's pre-picked payment method from the catalog. Optional;
|
|
// upper-cased channel code (e.g. `OVO`, `BCA_VIRTUAL_ACCOUNT`, `CARDS`).
|
|
// Stamped onto product_metadata for analytics AND translated via
|
|
// xenditInvoiceMethodFromCode() into Xendit's invoice paymentMethods[]
|
|
// filter so the customer doesn't have to re-pick on the hosted page.
|
|
preferredPaymentCode = null,
|
|
}) => {
|
|
if (!customerId) {
|
|
throw Object.assign(new Error('customerId is required'), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
|
}
|
|
if (typeof amount !== 'number' || amount < 0) {
|
|
throw Object.assign(new Error('amount must be a non-negative number'), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
|
}
|
|
if (productType === 'chat_session') {
|
|
if (typeof durationMinutes !== 'number' || durationMinutes <= 0) {
|
|
throw Object.assign(new Error('durationMinutes must be a positive number for chat_session'), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
|
}
|
|
if (mode !== SessionMode.CHAT && mode !== SessionMode.CALL) {
|
|
throw Object.assign(new Error('mode must be chat or call'), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
|
}
|
|
}
|
|
|
|
const ttl = ttlMinutes ?? await getPaymentRequestTimeoutMinutes()
|
|
|
|
// For chat_session, fold legacy args into product_metadata so the canonical
|
|
// location is the JSONB blob. Subscribers read from product_metadata.
|
|
const meta = productType === 'chat_session'
|
|
? {
|
|
duration_minutes: durationMinutes,
|
|
mode,
|
|
is_extension: isExtension,
|
|
targeted_mitra_id: targetedMitraId,
|
|
is_first_session_discount: isFirstSessionDiscount,
|
|
preferred_payment_code: preferredPaymentCode,
|
|
...productMetadata,
|
|
}
|
|
: { preferred_payment_code: preferredPaymentCode, ...productMetadata }
|
|
|
|
const [row] = await sql`
|
|
INSERT INTO payment_requests (
|
|
customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
|
|
status, targeted_mitra_id, mode, expires_at, product_type, product_metadata
|
|
)
|
|
VALUES (
|
|
${customerId}, ${amount}, ${durationMinutes ?? 0}, ${isFirstSessionDiscount}, ${isExtension},
|
|
${PaymentRequestStatus.PENDING}, ${targetedMitraId}, ${mode},
|
|
NOW() + (${ttl} || ' minutes')::interval,
|
|
${productType}, ${sql.json(meta)}
|
|
)
|
|
RETURNING *
|
|
`
|
|
|
|
// If Xendit is on, create the invoice + stamp the row. If creation fails, mark
|
|
// the row as `failed` (NOT `expired` — distinct: "we never got an invoice at all"
|
|
// vs. "TTL elapsed unpaid") and surface a 502 to the caller.
|
|
const xc = getXenditConfig()
|
|
if (xc.enabled) {
|
|
try {
|
|
const { invoiceId, invoiceUrl } = await createXenditInvoice({
|
|
paymentRequestId: row.id,
|
|
amount: row.amount,
|
|
ttlMinutes: ttl,
|
|
description: buildInvoiceDescription(row),
|
|
preferredPaymentCode,
|
|
})
|
|
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) {
|
|
console.error('[xendit] createInvoice failed; marking payment_request failed', { paymentRequestId: row.id, err })
|
|
const [failed] = await sql`
|
|
UPDATE payment_requests
|
|
SET status = ${PaymentRequestStatus.FAILED}
|
|
WHERE id = ${row.id} AND status = ${PaymentRequestStatus.PENDING}
|
|
RETURNING *
|
|
`
|
|
if (failed) emit('payment_request.failed', buildEventPayload(failed))
|
|
throw Object.assign(new Error('Payment provider error'), {
|
|
code: 'PAYMENT_PROVIDER_ERROR',
|
|
statusCode: 502,
|
|
cause: err,
|
|
})
|
|
}
|
|
}
|
|
|
|
return row
|
|
}
|
|
|
|
// --- confirmPayment ---
|
|
|
|
/**
|
|
* Transition pending → confirmed. Customer-facing callers (the legacy
|
|
* /payment-requests/:id/confirm route) verify customer ownership themselves before
|
|
* calling. Webhook caller does not check ownership (Xendit's authority).
|
|
*
|
|
* Optional xenditMeta stamps payment-time data from the webhook body:
|
|
* { invoiceId, paymentMethod, amount }
|
|
*
|
|
* Throws on missing row / wrong status / expiry. Webhook handler swallows
|
|
* INVALID_STATE (already confirmed) and EXPIRED (raced sweeper) and ACKs.
|
|
*
|
|
* Emits 'payment_request.confirmed' after commit.
|
|
*/
|
|
export const confirmPayment = async (paymentRequestId, xenditMeta = {}) => {
|
|
const [existing] = await sql`
|
|
SELECT id, customer_id, status, expires_at
|
|
FROM payment_requests
|
|
WHERE id = ${paymentRequestId}
|
|
`
|
|
if (!existing) {
|
|
throw Object.assign(new Error('Payment request not found'), { code: 'NOT_FOUND', statusCode: 404 })
|
|
}
|
|
if (existing.status !== PaymentRequestStatus.PENDING) {
|
|
throw Object.assign(new Error(`Payment request is ${existing.status}, cannot confirm`), {
|
|
code: 'INVALID_STATE', statusCode: 409,
|
|
})
|
|
}
|
|
if (new Date(existing.expires_at) <= new Date()) {
|
|
// Inline expiry check (sweeper hasn't run yet)
|
|
await sql`
|
|
UPDATE payment_requests SET status = ${PaymentRequestStatus.EXPIRED}
|
|
WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.PENDING}
|
|
`
|
|
throw Object.assign(new Error('Payment request has expired'), { code: 'EXPIRED', statusCode: 409 })
|
|
}
|
|
|
|
const [updated] = await sql`
|
|
UPDATE payment_requests
|
|
SET status = ${PaymentRequestStatus.CONFIRMED},
|
|
confirmed_at = NOW(),
|
|
xendit_invoice_id = COALESCE(${xenditMeta.invoiceId ?? null}, xendit_invoice_id),
|
|
xendit_payment_method = COALESCE(${xenditMeta.paymentMethod ?? null}, xendit_payment_method),
|
|
xendit_paid_amount = COALESCE(${xenditMeta.amount ?? null}, xendit_paid_amount)
|
|
WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.PENDING}
|
|
RETURNING *
|
|
`
|
|
if (!updated) {
|
|
throw Object.assign(new Error('Payment request state changed during confirm'), { code: 'CONFLICT', statusCode: 409 })
|
|
}
|
|
|
|
emit('payment_request.confirmed', buildEventPayload(updated))
|
|
return updated
|
|
}
|
|
|
|
// Customer-facing wrapper used by the legacy /payment-requests/:id/confirm route
|
|
// (kept only for dev/Maestro — production gates the route on XENDIT_ENABLED).
|
|
// Verifies customer ownership before delegating to the internal confirmPayment.
|
|
export const confirmPaymentForCustomer = async (paymentRequestId, customerId) => {
|
|
const [existing] = await sql`SELECT customer_id FROM payment_requests WHERE id = ${paymentRequestId}`
|
|
if (!existing) {
|
|
throw Object.assign(new Error('Payment request not found'), { code: 'NOT_FOUND', statusCode: 404 })
|
|
}
|
|
if (existing.customer_id !== customerId) {
|
|
throw Object.assign(new Error('Payment request does not belong to this customer'), { code: 'FORBIDDEN', statusCode: 403 })
|
|
}
|
|
return confirmPayment(paymentRequestId)
|
|
}
|
|
|
|
// --- expirePayment (webhook-callable; no ownership check) ---
|
|
|
|
/**
|
|
* Transition pending → expired. Called by the Xendit EXPIRED webhook handler
|
|
* and by the background sweeper. Idempotent — if already terminal, no-op.
|
|
* Emits 'payment_request.expired' if a transition occurred.
|
|
*/
|
|
export const expirePayment = async (paymentRequestId) => {
|
|
const [updated] = await sql`
|
|
UPDATE payment_requests
|
|
SET status = ${PaymentRequestStatus.EXPIRED}
|
|
WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.PENDING}
|
|
RETURNING *
|
|
`
|
|
if (updated) emit('payment_request.expired', buildEventPayload(updated))
|
|
return updated ?? null
|
|
}
|
|
|
|
// --- consumePayment ---
|
|
|
|
/**
|
|
* Transition confirmed → consumed. Called by product code (pairing service for chat)
|
|
* after successful delivery. No event — terminal success.
|
|
*/
|
|
export const consumePayment = async (paymentRequestId) => {
|
|
const [updated] = await sql`
|
|
UPDATE payment_requests
|
|
SET status = ${PaymentRequestStatus.CONSUMED}, consumed_at = NOW()
|
|
WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.CONFIRMED}
|
|
RETURNING id, status, consumed_at
|
|
`
|
|
return updated ?? null
|
|
}
|
|
|
|
// --- markDeliveryFailed ---
|
|
|
|
/**
|
|
* TERMINAL: mark a confirmed payment as failed_delivery + write a pairing_failures
|
|
* audit row. Idempotent: no-op if not currently confirmed.
|
|
*
|
|
* Use only when no fallback is possible (no mitra available, all rejected, etc.).
|
|
* For intermediate failures with a fallback (targeted reject during returning-chat),
|
|
* use recordIntermediateFailure() which keeps the payment confirmed.
|
|
*
|
|
* Emits 'payment_request.delivery_failed' on transition.
|
|
*/
|
|
export const markDeliveryFailed = async (paymentRequestId, causeTag) => {
|
|
if (!Object.values(PairingFailureCause).includes(causeTag)) {
|
|
throw Object.assign(new Error(`Unknown cause_tag: ${causeTag}`), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
|
}
|
|
|
|
const [existing] = await sql`
|
|
SELECT id, customer_id, targeted_mitra_id, amount, status, product_type, product_metadata, xendit_invoice_id, xendit_payment_method
|
|
FROM payment_requests
|
|
WHERE id = ${paymentRequestId}
|
|
`
|
|
if (!existing) return null
|
|
if (existing.status !== PaymentRequestStatus.CONFIRMED) return existing // idempotent
|
|
|
|
const [updated] = await sql`
|
|
UPDATE payment_requests
|
|
SET status = ${PaymentRequestStatus.FAILED_DELIVERY}
|
|
WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.CONFIRMED}
|
|
RETURNING *
|
|
`
|
|
if (!updated) return existing
|
|
|
|
await recordFailure({
|
|
paymentRequestId,
|
|
customerId: existing.customer_id,
|
|
targetedMitraId: existing.targeted_mitra_id,
|
|
causeTag,
|
|
amount: existing.amount,
|
|
})
|
|
|
|
emit('payment_request.delivery_failed', { ...buildEventPayload(updated), causeTag })
|
|
return updated
|
|
}
|
|
|
|
// Backward-compat alias — pairing.service still calls failPaymentSession by name.
|
|
// TODO follow-up phase: rename call sites and drop this alias.
|
|
export const failPaymentSession = markDeliveryFailed
|
|
|
|
// --- recordIntermediateFailure (audit only; doesn't terminate) ---
|
|
|
|
/**
|
|
* INTERMEDIATE: write a pairing_failures audit row WITHOUT terminating the payment.
|
|
* Used inside flows with a fallback path (targeted "Curhat lagi" reject can fall back
|
|
* to general blast on the same payment). One payment_request may have many audit rows.
|
|
*
|
|
* Returns the inserted pairing_failures row, or null if the payment is missing.
|
|
*/
|
|
export const recordIntermediateFailure = async ({
|
|
paymentRequestId,
|
|
customerId,
|
|
targetedMitraId = null,
|
|
causeTag,
|
|
amount,
|
|
}) => {
|
|
if (!Object.values(PairingFailureCause).includes(causeTag)) {
|
|
throw Object.assign(new Error(`Unknown cause_tag: ${causeTag}`), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
|
}
|
|
const [existing] = await sql`SELECT id FROM payment_requests WHERE id = ${paymentRequestId}`
|
|
if (!existing) return null
|
|
return recordFailure({ paymentRequestId, customerId, targetedMitraId, causeTag, amount })
|
|
}
|
|
|
|
// --- cancelPayment (customer-initiated abandonment) ---
|
|
|
|
/**
|
|
* Customer-initiated abandonment of a pending payment (closed payment screen).
|
|
* No pairing_failures row (no money moved). Idempotent.
|
|
*
|
|
* Emits 'payment_request.cancelled' on transition.
|
|
*/
|
|
export const cancelPayment = async (paymentRequestId, customerId) => {
|
|
const [existing] = await sql`
|
|
SELECT id, customer_id, status FROM payment_requests WHERE id = ${paymentRequestId}
|
|
`
|
|
if (!existing) {
|
|
throw Object.assign(new Error('Payment request not found'), { code: 'NOT_FOUND', statusCode: 404 })
|
|
}
|
|
if (existing.customer_id !== customerId) {
|
|
throw Object.assign(new Error('Payment request does not belong to this customer'), { code: 'FORBIDDEN', statusCode: 403 })
|
|
}
|
|
if (existing.status !== PaymentRequestStatus.PENDING) {
|
|
return existing // idempotent
|
|
}
|
|
|
|
const [updated] = await sql`
|
|
UPDATE payment_requests SET status = ${PaymentRequestStatus.ABANDONED}
|
|
WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.PENDING}
|
|
RETURNING *
|
|
`
|
|
if (updated) emit('payment_request.cancelled', buildEventPayload(updated))
|
|
return updated ?? existing
|
|
}
|
|
|
|
// --- Background sweeper + reconciliation ---
|
|
|
|
/**
|
|
* Runs every 60s from server.js + once at startup.
|
|
*
|
|
* Three jobs:
|
|
* 1. Pending past expires_at → expired (no failure row; emits .expired)
|
|
* 2. Confirmed past expires_at AND not consumed → failed_delivery (writes pairing_failures, emits .delivery_failed)
|
|
* 3. Confirmed-with-no-chat-session-yet → re-invoke pairing subscriber (lost-event recovery)
|
|
*
|
|
* Job 3 is the durability backstop — if a process restart lost the in-process
|
|
* EventEmitter notification before pairing started, the sweeper re-triggers it
|
|
* on the next tick. Subscribers MUST be idempotent (the pairing subscriber checks
|
|
* "chat_sessions WHERE payment_request_id = ?" before doing work).
|
|
*/
|
|
export const expireStalePaymentRequests = async () => {
|
|
// 1) pending → expired (emit event for each)
|
|
const expired = await sql`
|
|
UPDATE payment_requests
|
|
SET status = ${PaymentRequestStatus.EXPIRED}
|
|
WHERE status = ${PaymentRequestStatus.PENDING}
|
|
AND expires_at <= NOW()
|
|
RETURNING *
|
|
`
|
|
for (const row of expired) emit('payment_request.expired', buildEventPayload(row))
|
|
|
|
// 2) confirmed-but-stale → failed_delivery
|
|
const flipped = await sql`
|
|
UPDATE payment_requests
|
|
SET status = ${PaymentRequestStatus.FAILED_DELIVERY}
|
|
WHERE status = ${PaymentRequestStatus.CONFIRMED}
|
|
AND expires_at <= NOW()
|
|
RETURNING *
|
|
`
|
|
await Promise.all(flipped.map(async (row) => {
|
|
await recordFailure({
|
|
paymentRequestId: row.id,
|
|
customerId: row.customer_id,
|
|
targetedMitraId: row.targeted_mitra_id,
|
|
causeTag: PairingFailureCause.PAYMENT_REQUEST_EXPIRED,
|
|
amount: row.amount,
|
|
})
|
|
// Customer-facing: push terminal PAIRING_FAILED via WS; FCM fallback if not connected.
|
|
try {
|
|
const wsSent = sendToUser(UserType.CUSTOMER, row.customer_id, {
|
|
type: WsMessage.PAIRING_FAILED,
|
|
payment_request_id: row.id,
|
|
cause_tag: PairingFailureCause.PAYMENT_REQUEST_EXPIRED,
|
|
is_terminal: true,
|
|
})
|
|
if (!wsSent) {
|
|
await sendPushNotification(UserType.CUSTOMER, row.customer_id, {
|
|
title: 'Sesi gagal',
|
|
body: 'Sesi pembayaranmu telah berakhir. Silakan mulai ulang.',
|
|
data: {
|
|
type: WsMessage.PAIRING_FAILED,
|
|
payment_request_id: row.id,
|
|
cause_tag: PairingFailureCause.PAYMENT_REQUEST_EXPIRED,
|
|
},
|
|
})
|
|
}
|
|
} catch (err) {
|
|
console.error('expireStalePaymentRequests: failed to notify customer', {
|
|
paymentRequestId: row.id, customerId: row.customer_id, err,
|
|
})
|
|
}
|
|
emit('payment_request.delivery_failed', {
|
|
...buildEventPayload(row),
|
|
causeTag: PairingFailureCause.PAYMENT_REQUEST_EXPIRED,
|
|
})
|
|
}))
|
|
|
|
// 3) Reconciliation — confirmed payments that should have started product work but didn't.
|
|
// For chat_session: no chat_sessions row exists AND no pairing_failures row exists.
|
|
// The 30-second buffer avoids racing with happy-path subscribers mid-flight.
|
|
const orphans = await sql`
|
|
SELECT *
|
|
FROM payment_requests pr
|
|
WHERE pr.status = ${PaymentRequestStatus.CONFIRMED}
|
|
AND pr.confirmed_at < NOW() - INTERVAL '30 seconds'
|
|
AND pr.product_type = 'chat_session'
|
|
AND NOT EXISTS (SELECT 1 FROM chat_sessions cs WHERE cs.payment_request_id = pr.id)
|
|
AND NOT EXISTS (SELECT 1 FROM pairing_failures pf WHERE pf.payment_request_id = pr.id)
|
|
LIMIT 100
|
|
`
|
|
let reconciled = 0
|
|
for (const row of orphans) {
|
|
try {
|
|
// Re-emit the event the subscriber missed. Subscriber's idempotency check
|
|
// makes this safe even if the subscriber did actually run (just slow).
|
|
emit('payment_request.confirmed', buildEventPayload(row))
|
|
reconciled++
|
|
} catch (err) {
|
|
console.error('[reconciler] re-emit failed', { paymentRequestId: row.id, err })
|
|
}
|
|
}
|
|
|
|
return { expired: expired.length, failed: flipped.length, reconciled }
|
|
}
|
|
|
|
// Backward-compat alias — server.js previously called expireStalePaymentSessions
|
|
export const expireStalePaymentSessions = expireStalePaymentRequests
|
|
|
|
// --- getPayment ---
|
|
|
|
export const getPayment = async (id) => {
|
|
const [row] = await sql`SELECT * FROM payment_requests WHERE id = ${id}`
|
|
return row ?? null
|
|
}
|
|
|
|
// Backward-compat aliases (legacy callers — to be migrated in a follow-up pass)
|
|
export const createPaymentSession = (args) => requestPayment(args)
|
|
export const confirmPaymentSession = confirmPaymentForCustomer
|
|
export const consumePaymentSession = consumePayment
|
|
export const abandonPaymentSession = cancelPayment
|
|
export const getPaymentSession = getPayment
|
|
|
|
// --- getCustomerPendingPayments (unchanged shape) ---
|
|
|
|
/**
|
|
* Phase 4 Stage 10 — Chat Tab Pembayaran feed.
|
|
* Returns the customer's pending payment requests (initial + extension) that
|
|
* haven't paid AND haven't expired.
|
|
*/
|
|
export const getCustomerPendingPayments = async (customerId) => {
|
|
const items = await sql`
|
|
SELECT
|
|
ps.id,
|
|
ps.is_extension,
|
|
ps.amount,
|
|
ps.duration_minutes,
|
|
ps.mode,
|
|
ps.created_at,
|
|
ps.expires_at,
|
|
COALESCE(ext_m.id, tgt_m.id) AS mitra_id,
|
|
COALESCE(ext_m.display_name, tgt_m.display_name) AS mitra_display_name
|
|
FROM payment_requests ps
|
|
LEFT JOIN session_extensions se ON se.payment_request_id = ps.id
|
|
LEFT JOIN chat_sessions cs ON cs.id = se.session_id
|
|
LEFT JOIN mitras ext_m ON ext_m.id = cs.mitra_id
|
|
LEFT JOIN mitras tgt_m ON tgt_m.id = ps.targeted_mitra_id
|
|
WHERE ps.customer_id = ${customerId}
|
|
AND ps.status = ${PaymentRequestStatus.PENDING}
|
|
AND ps.expires_at > NOW()
|
|
ORDER BY ps.created_at DESC
|
|
`
|
|
return { items, total: items.length }
|
|
}
|
|
|
|
// --- Pairing subscriber wiring (called from server.js at startup) ---
|
|
|
|
/**
|
|
* Wires pairing.service as a subscriber to payment_request.confirmed.
|
|
* Filters on productType so future product subscribers can coexist.
|
|
* Subscriber implements its own idempotency (skip if chat_sessions row exists).
|
|
*
|
|
* Imported lazily to avoid a circular import: pairing.service imports payment.service
|
|
* for failPaymentSession + consumePayment + getPayment.
|
|
*/
|
|
export const registerPairingSubscriber = () => {
|
|
on('payment_request.confirmed', async (evt) => {
|
|
if (evt.productType !== 'chat_session') return
|
|
const { startPairingFromPaymentRequest } = await import('./pairing.service.js')
|
|
await startPairingFromPaymentRequest({
|
|
paymentRequestId: evt.paymentRequestId,
|
|
productMetadata: evt.productMetadata,
|
|
customerId: evt.customerId,
|
|
})
|
|
})
|
|
}
|