Phase 5 Xendit: Stages 1-7 (XENDIT_ENABLED=false; Stage 8 pending creds)

Backend
- payment_sessions → payment_requests rename across DB schema + 29 files
- payment.service.js becomes product-agnostic owner: EventEmitter +
  Xendit wrapper + requestPayment / confirmPayment public API; legacy
  aliases retained for existing chat callers
- Webhook handler at POST /api/shared/payment/webhooks/xendit, with
  constant-time token verification (8 vitest cases)
- Server-driven pairing: payment.service emits
  payment_request.confirmed → pairing subscriber starts the blast.
  Legacy POST /chat/request still works during the cutover.
- Reconciliation sweeper extended (re-emits events for confirmed rows
  with no chat session)
- SIGTERM drain + startup reconciliation pass in server.js

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 12:52:33 +08:00
parent e6d991373e
commit 3fff4b1c6e
37 changed files with 2805 additions and 515 deletions

View File

@@ -13,7 +13,7 @@ import {
TransactionType,
WsMessage,
TopicSensitivity,
PaymentSessionStatus,
PaymentRequestStatus,
PairingFailureCause,
PairingRequestType,
} from '../constants.js'
@@ -68,7 +68,7 @@ const notifyCustomer = async (customerId, data) => {
body: 'Maaf, kami tidak bisa menemukan bestie untuk sesimu. Tim kami akan menghubungimu segera.',
data: {
type: WsMessage.PAIRING_FAILED,
payment_session_id: data.payment_session_id || '',
payment_request_id: data.payment_request_id || '',
cause_tag: data.cause_tag || '',
},
})
@@ -100,41 +100,41 @@ export const findAvailableMitras = async () => {
* Validate that a payment session is owned by the customer, confirmed, and not yet consumed.
* Throws on mismatch. Returns the loaded payment session row.
*/
const requireConfirmedPaymentSession = async (paymentSessionId, customerId, { allowExtension = false } = {}) => {
if (!paymentSessionId) {
throw Object.assign(new Error('payment_session_id is required'), {
const requireConfirmedPaymentRequest = async (paymentRequestId, customerId, { allowExtension = false } = {}) => {
if (!paymentRequestId) {
throw Object.assign(new Error('payment_request_id is required'), {
code: 'VALIDATION_ERROR', statusCode: 422,
})
}
const paySession = await getPaymentSession(paymentSessionId)
if (!paySession) {
const payRequest = await getPaymentSession(paymentRequestId)
if (!payRequest) {
throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 })
}
if (paySession.customer_id !== customerId) {
if (payRequest.customer_id !== customerId) {
throw Object.assign(new Error('Payment session does not belong to this customer'), {
code: 'FORBIDDEN', statusCode: 403,
})
}
if (paySession.status !== PaymentSessionStatus.CONFIRMED) {
throw Object.assign(new Error(`Payment session is ${paySession.status}, must be confirmed`), {
if (payRequest.status !== PaymentRequestStatus.CONFIRMED) {
throw Object.assign(new Error(`Payment session is ${payRequest.status}, must be confirmed`), {
code: 'INVALID_STATE', statusCode: 409,
})
}
if (paySession.is_extension && !allowExtension) {
if (payRequest.is_extension && !allowExtension) {
throw Object.assign(new Error('Extension payment session cannot be used to start a new chat'), {
code: 'INVALID_STATE', statusCode: 409,
})
}
if (new Date(paySession.expires_at) <= new Date()) {
if (new Date(payRequest.expires_at) <= new Date()) {
// Check expiry inline at every state transition (defense in depth vs. the background sweeper).
await failPaymentSession(paymentSessionId, PairingFailureCause.PAYMENT_SESSION_EXPIRED)
await failPaymentSession(paymentRequestId, PairingFailureCause.PAYMENT_REQUEST_EXPIRED)
throw Object.assign(new Error('Payment session has expired'), { code: 'EXPIRED', statusCode: 409 })
}
return paySession
return payRequest
}
/**
* General-blast pairing request. Requires a confirmed payment_session_id.
* General-blast pairing request. Requires a confirmed payment_request_id.
*
* The duration_minutes / price / is_first_session_discount values for the chat_session row are
* sourced from the payment session — the client does not dictate pricing here.
@@ -144,12 +144,12 @@ const requireConfirmedPaymentSession = async (paymentSessionId, customerId, { al
* fall back to general blast on the same payment. The flag bypasses the
* "use returning-chat endpoint" guard in that exact case.
*/
export const createPairingRequest = async (customerId, { paymentSessionId, topic_sensitivity, allowTargetedPayment = false } = {}) => {
const paySession = await requireConfirmedPaymentSession(paymentSessionId, customerId)
export const createPairingRequest = async (customerId, { paymentRequestId, topic_sensitivity, allowTargetedPayment = false } = {}) => {
const payRequest = await requireConfirmedPaymentRequest(paymentRequestId, customerId)
// Targeted payment session must use createTargetedPairingRequest unless we're
// explicitly invoked by the fallback-to-blast path.
if (paySession.targeted_mitra_id && !allowTargetedPayment) {
if (payRequest.targeted_mitra_id && !allowTargetedPayment) {
throw Object.assign(new Error('Payment session is targeted to a specific mitra; use returning-chat endpoint'), {
code: 'INVALID_STATE', statusCode: 409,
})
@@ -170,7 +170,7 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic
const availableMitras = await findAvailableMitras()
if (availableMitras.length === 0) {
// No mitras to blast to — fail the payment immediately.
await failPaymentSession(paymentSessionId, PairingFailureCause.NO_MITRA_AVAILABLE)
await failPaymentSession(paymentRequestId, PairingFailureCause.NO_MITRA_AVAILABLE)
throw Object.assign(new Error('No bestie available'), {
code: 'NO_MITRA_AVAILABLE', statusCode: 404,
})
@@ -183,14 +183,14 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic
// Create session sourced from the payment session.
const [session] = await sql`
INSERT INTO chat_sessions (
customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id
customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_request_id
)
VALUES (
${customerId}, ${SessionStatus.PENDING_ACCEPTANCE},
${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_first_session_discount},
${resolvedTopic}, ${paymentSessionId}
${payRequest.duration_minutes}, ${payRequest.amount}, ${payRequest.is_first_session_discount},
${resolvedTopic}, ${paymentRequestId}
)
RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id, created_at
RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_request_id, created_at
`
// Fan out to all available mitras in parallel — DB inserts and notifications are
@@ -225,6 +225,56 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic
return session
}
/**
* Phase 5: server-driven pairing entry point — called by the payment service's
* `payment_request.confirmed` event subscriber. Replaces the client-driven
* POST /chat/request path for new payments.
*
* **Idempotent.** If a chat_session already exists for this payment_request_id,
* returns it without doing anything. Safe to call multiple times — the
* reconciliation sweeper relies on this property to retry lost events.
*
* Routes general-blast vs targeted based on productMetadata.targeted_mitra_id.
* Errors are caught and logged (don't bubble — the event subscriber wrapper would
* also catch but logging here gives better context).
*/
export const startPairingFromPaymentRequest = async ({ paymentRequestId, productMetadata, customerId }) => {
// Idempotency check — covers webhook retries, reconciliation sweeper re-emit,
// and the case where the legacy client still POSTs to /chat/request after our
// subscriber already started pairing.
const [existing] = await sql`
SELECT id FROM chat_sessions WHERE payment_request_id = ${paymentRequestId}
`
if (existing) return existing
const targetedMitraId = productMetadata?.targeted_mitra_id ?? null
const topicSensitivity = productMetadata?.topic_sensitivity ?? TopicSensitivity.REGULAR
try {
if (targetedMitraId) {
return await createTargetedPairingRequest(customerId, {
paymentRequestId,
targetedMitraId,
topic_sensitivity: topicSensitivity,
})
}
return await createPairingRequest(customerId, {
paymentRequestId,
topic_sensitivity: topicSensitivity,
})
} catch (err) {
// Already-active is benign — covers the race where the legacy /chat/request
// beat the subscriber to it. NO_MITRA_AVAILABLE has already failed the payment
// (createPairingRequest does that internally) — we don't need to act further.
if (err.code === 'ALREADY_ACTIVE' || err.code === 'NO_MITRA_AVAILABLE') {
console.log(`[pairing subscriber] ${err.code} for payment ${paymentRequestId} — already handled`)
return null
}
console.error('[pairing subscriber] startPairing failed', { paymentRequestId, err })
throw err
}
}
/**
* Targeted pairing request for "Curhat lagi" (returning chat).
*
@@ -236,14 +286,14 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic
* - On explicit decline by mitra: fail payment with `targeted_mitra_rejected`, push WS event.
* - On accept: existing accept path runs (consumes payment session as for general blast).
*/
export const createTargetedPairingRequest = async (customerId, { paymentSessionId, targetedMitraId, topic_sensitivity } = {}) => {
const paySession = await requireConfirmedPaymentSession(paymentSessionId, customerId)
export const createTargetedPairingRequest = async (customerId, { paymentRequestId, targetedMitraId, topic_sensitivity } = {}) => {
const payRequest = await requireConfirmedPaymentRequest(paymentRequestId, customerId)
if (!targetedMitraId) {
throw Object.assign(new Error('targetedMitraId is required'), { code: 'VALIDATION_ERROR', statusCode: 422 })
}
// Cross-check: payment_session.targeted_mitra_id should match (if set).
if (paySession.targeted_mitra_id && paySession.targeted_mitra_id !== targetedMitraId) {
// Cross-check: payment_request.targeted_mitra_id should match (if set).
if (payRequest.targeted_mitra_id && payRequest.targeted_mitra_id !== targetedMitraId) {
throw Object.assign(new Error('targetedMitraId does not match payment session'), {
code: 'INVALID_STATE', statusCode: 409,
})
@@ -267,11 +317,11 @@ export const createTargetedPairingRequest = async (customerId, { paymentSessionI
// Intermediate failure: audit row written, payment stays `confirmed` so the customer
// can choose to fall back to general blast (or cancel, which terminates).
await recordIntermediateFailure({
paymentSessionId,
paymentRequestId,
customerId,
targetedMitraId,
causeTag: PairingFailureCause.TARGETED_MITRA_OFFLINE,
amount: paySession.amount,
amount: payRequest.amount,
})
throw Object.assign(new Error('Targeted mitra is offline'), {
code: 'TARGETED_MITRA_OFFLINE', statusCode: 409, reason: 'targeted_mitra_offline',
@@ -285,11 +335,11 @@ export const createTargetedPairingRequest = async (customerId, { paymentSessionI
const midSessionWithCustomer = await isMitraInActiveSessionWithCustomer(targetedMitraId, customerId)
if (!midSessionWithCustomer) {
await recordIntermediateFailure({
paymentSessionId,
paymentRequestId,
customerId,
targetedMitraId,
causeTag: PairingFailureCause.TARGETED_MITRA_OFFLINE,
amount: paySession.amount,
amount: payRequest.amount,
})
throw Object.assign(new Error('Targeted mitra is at capacity'), {
code: 'TARGETED_MITRA_OFFLINE', statusCode: 409, reason: 'targeted_mitra_offline',
@@ -305,14 +355,14 @@ export const createTargetedPairingRequest = async (customerId, { paymentSessionI
// Create session sourced from the payment session, status = pending_acceptance.
const [session] = await sql`
INSERT INTO chat_sessions (
customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id
customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_request_id
)
VALUES (
${customerId}, ${SessionStatus.PENDING_ACCEPTANCE},
${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_first_session_discount},
${resolvedTopic}, ${paymentSessionId}
${payRequest.duration_minutes}, ${payRequest.amount}, ${payRequest.is_first_session_discount},
${resolvedTopic}, ${paymentRequestId}
)
RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id, created_at
RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_request_id, created_at
`
// Single notification to the targeted mitra
@@ -355,7 +405,7 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
UPDATE chat_sessions
SET mitra_id = ${mitraId}, status = ${SessionStatus.PENDING_PAYMENT}, paired_at = NOW()
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} AND mitra_id IS NULL
RETURNING id, customer_id, mitra_id, status, paired_at, payment_session_id
RETURNING id, customer_id, mitra_id, status, paired_at, payment_request_id
`
if (!session) {
@@ -386,8 +436,8 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
}
// Consume the payment session at the moment of acceptance.
if (session.payment_session_id) {
await consumePaymentSession(session.payment_session_id)
if (session.payment_request_id) {
await consumePaymentSession(session.payment_request_id)
}
// Activate the session and set expires_at.
@@ -399,7 +449,7 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
ELSE NULL
END
WHERE id = ${sessionId}
RETURNING id, customer_id, mitra_id, status, paired_at, duration_minutes, price, is_first_session_discount, expires_at, payment_session_id
RETURNING id, customer_id, mitra_id, status, paired_at, duration_minutes, price, is_first_session_discount, expires_at, payment_request_id
`
// Record transaction
@@ -453,25 +503,25 @@ export const declinePairingRequest = async (sessionId, mitraId) => {
WHERE session_id = ${sessionId} AND mitra_id = ${mitraId} AND response IS NULL
`
// Targeted-vs-general is determined by the payment_session.targeted_mitra_id, not by
// Targeted-vs-general is determined by the payment_request.targeted_mitra_id, not by
// notification count — a general blast with only one online mitra also has length=1.
const [targetCheck] = await sql`
SELECT ps.targeted_mitra_id
FROM chat_sessions cs
LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id
LEFT JOIN payment_requests ps ON ps.id = cs.payment_request_id
WHERE cs.id = ${sessionId}
`
const isTargeted = !!targetCheck?.targeted_mitra_id
if (isTargeted) {
// Mark the chat_session as expired (the targeted attempt is over) — but keep the
// payment_session in `confirmed` so the customer can fall back to general blast on
// payment_request in `confirmed` so the customer can fall back to general blast on
// the same payment, or cancel (which then terminates).
const [session] = await sql`
UPDATE chat_sessions
SET status = ${SessionStatus.EXPIRED}
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE}
RETURNING id, customer_id, payment_session_id
RETURNING id, customer_id, payment_request_id
`
if (session) {
// Clear the 20s timer if still pending.
@@ -482,15 +532,15 @@ export const declinePairingRequest = async (sessionId, mitraId) => {
}
// Audit row only; payment session stays `confirmed`.
if (session.payment_session_id) {
const paySession = await getPaymentSession(session.payment_session_id)
if (paySession) {
if (session.payment_request_id) {
const payRequest = await getPaymentSession(session.payment_request_id)
if (payRequest) {
await recordIntermediateFailure({
paymentSessionId: session.payment_session_id,
paymentRequestId: session.payment_request_id,
customerId: session.customer_id,
targetedMitraId: mitraId,
causeTag: PairingFailureCause.TARGETED_MITRA_REJECTED,
amount: paySession.amount,
amount: payRequest.amount,
})
}
}
@@ -499,7 +549,7 @@ export const declinePairingRequest = async (sessionId, mitraId) => {
await notifyCustomer(session.customer_id, {
type: WsMessage.RETURNING_CHAT_REJECTED,
session_id: sessionId,
payment_session_id: session.payment_session_id,
payment_request_id: session.payment_request_id,
})
}
return
@@ -518,7 +568,7 @@ export const declinePairingRequest = async (sessionId, mitraId) => {
UPDATE chat_sessions
SET status = ${SessionStatus.EXPIRED}
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE}
RETURNING id, customer_id, payment_session_id
RETURNING id, customer_id, payment_request_id
`
if (session) {
const timeoutId = pairingTimeouts.get(sessionId)
@@ -529,14 +579,14 @@ export const declinePairingRequest = async (sessionId, mitraId) => {
// Intermediate failure: payment stays confirmed so the customer can re-blast
// from the S7 timeout CTA. Audit row is still written.
if (session.payment_session_id) {
const paySession = await getPaymentSession(session.payment_session_id)
if (paySession) {
if (session.payment_request_id) {
const payRequest = await getPaymentSession(session.payment_request_id)
if (payRequest) {
await recordIntermediateFailure({
paymentSessionId: session.payment_session_id,
paymentRequestId: session.payment_request_id,
customerId: session.customer_id,
causeTag: PairingFailureCause.ALL_MITRAS_REJECTED,
amount: paySession.amount,
amount: payRequest.amount,
})
}
}
@@ -544,7 +594,7 @@ export const declinePairingRequest = async (sessionId, mitraId) => {
await notifyCustomer(session.customer_id, {
type: WsMessage.PAIRING_FAILED,
session_id: sessionId,
payment_session_id: session.payment_session_id,
payment_request_id: session.payment_request_id,
cause_tag: PairingFailureCause.ALL_MITRAS_REJECTED,
is_terminal: false,
})
@@ -558,7 +608,7 @@ export const cancelPairingRequest = async (sessionId, customerId) => {
SET status = ${SessionStatus.CANCELLED}
WHERE id = ${sessionId} AND customer_id = ${customerId}
AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE})
RETURNING id, customer_id, status, payment_session_id
RETURNING id, customer_id, status, payment_request_id
`
if (!session) {
@@ -594,8 +644,8 @@ export const cancelPairingRequest = async (sessionId, customerId) => {
// Customer initiated this cancel; the calling client already navigates home. Do not
// push PAIRING_FAILED for customer-initiated cancels — surfacing it as a "failure"
// event (especially via FCM if backgrounded) misframes the user's own action.
if (session.payment_session_id) {
await failPaymentSession(session.payment_session_id, PairingFailureCause.CUSTOMER_CANCELLED)
if (session.payment_request_id) {
await failPaymentSession(session.payment_request_id, PairingFailureCause.CUSTOMER_CANCELLED)
}
return session
@@ -609,12 +659,12 @@ export const cancelPairingRequest = async (sessionId, customerId) => {
* If a chat_session was already created (general blast in flight, or targeted request out),
* we cancel that too.
*/
export const cancelPaymentSearch = async (paymentSessionId, customerId) => {
const paySession = await getPaymentSession(paymentSessionId)
if (!paySession) {
export const cancelPaymentSearch = async (paymentRequestId, customerId) => {
const payRequest = await getPaymentSession(paymentRequestId)
if (!payRequest) {
throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 })
}
if (paySession.customer_id !== customerId) {
if (payRequest.customer_id !== customerId) {
throw Object.assign(new Error('Payment session does not belong to this customer'), {
code: 'FORBIDDEN', statusCode: 403,
})
@@ -623,7 +673,7 @@ export const cancelPaymentSearch = async (paymentSessionId, customerId) => {
// If a chat_session exists for this payment in pending_acceptance/searching, cancel it.
const [linkedSession] = await sql`
SELECT id FROM chat_sessions
WHERE payment_session_id = ${paymentSessionId}
WHERE payment_request_id = ${paymentRequestId}
AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE})
`
if (linkedSession) {
@@ -634,37 +684,37 @@ export const cancelPaymentSearch = async (paymentSessionId, customerId) => {
// Otherwise fail the payment directly. Covers the case where the customer cancels after
// the targeted attempt already expired/rejected (chat_session no longer pending_acceptance)
// but the payment is still `confirmed`. No customer-side WS push — see cancelPairingRequest.
if (paySession.status === PaymentSessionStatus.CONFIRMED) {
await failPaymentSession(paymentSessionId, PairingFailureCause.CUSTOMER_CANCELLED)
if (payRequest.status === PaymentRequestStatus.CONFIRMED) {
await failPaymentSession(paymentRequestId, PairingFailureCause.CUSTOMER_CANCELLED)
}
return { id: paymentSessionId, payment_session_id: paymentSessionId }
return { id: paymentRequestId, payment_request_id: paymentRequestId }
}
/**
* After a returning-chat fail, customer taps "Chat dengan bestie lain".
*
* The original payment_session stays in `confirmed` for the entire returning-chat flow —
* The original payment_request stays in `confirmed` for the entire returning-chat flow —
* targeted reject/timeout writes an audit-only `pairing_failures` row but does NOT terminate.
* So when the customer falls back to general blast, we reuse the same `payment_session_id`
* directly. Multiple `pairing_failures` rows may FK from one payment_session — that's the
* So when the customer falls back to general blast, we reuse the same `payment_request_id`
* directly. Multiple `pairing_failures` rows may FK from one payment_request — that's the
* desired CC UX (one row per failed attempt). Termination happens only at the actual end
* of the flow (chat starts → consumed; cancel/blast-exhaust → failed_pairing).
* of the flow (chat starts → consumed; cancel/blast-exhaust → failed_delivery).
*
* The targeted_mitra_id flag on the original row is left as-is (it records the customer's
* original intent); the general blast happens regardless.
*/
export const fallbackToGeneralBlast = async (paymentSessionId, customerId, { topic_sensitivity } = {}) => {
const paySession = await getPaymentSession(paymentSessionId)
if (!paySession) {
export const fallbackToGeneralBlast = async (paymentRequestId, customerId, { topic_sensitivity } = {}) => {
const payRequest = await getPaymentSession(paymentRequestId)
if (!payRequest) {
throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 })
}
if (paySession.customer_id !== customerId) {
if (payRequest.customer_id !== customerId) {
throw Object.assign(new Error('Payment session does not belong to this customer'), {
code: 'FORBIDDEN', statusCode: 403,
})
}
if (paySession.status !== PaymentSessionStatus.CONFIRMED) {
throw Object.assign(new Error(`Cannot fallback from payment in status ${paySession.status}`), {
if (payRequest.status !== PaymentRequestStatus.CONFIRMED) {
throw Object.assign(new Error(`Cannot fallback from payment in status ${payRequest.status}`), {
code: 'INVALID_STATE', statusCode: 409,
})
}
@@ -672,7 +722,7 @@ export const fallbackToGeneralBlast = async (paymentSessionId, customerId, { top
// Run the general blast against the SAME payment session. Pass `allowTargetedPayment`
// so the targeted_mitra_id on the payment session doesn't trip the general-blast guard.
return createPairingRequest(customerId, {
paymentSessionId,
paymentRequestId,
topic_sensitivity,
allowTargetedPayment: true,
})
@@ -683,7 +733,7 @@ export const expirePairingRequest = async (sessionId, causeTag = PairingFailureC
UPDATE chat_sessions
SET status = ${SessionStatus.EXPIRED}
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE}
RETURNING id, customer_id, status, payment_session_id
RETURNING id, customer_id, status, payment_request_id
`
if (!session) return null
@@ -699,14 +749,14 @@ export const expirePairingRequest = async (sessionId, causeTag = PairingFailureC
// Intermediate failure: payment session stays `confirmed` so the customer can
// re-blast on the same payment from the S7 timeout CTA. Audit row is still
// written so the failed-pairing CC view captures every attempt.
if (session.payment_session_id) {
const paySession = await getPaymentSession(session.payment_session_id)
if (paySession) {
if (session.payment_request_id) {
const payRequest = await getPaymentSession(session.payment_request_id)
if (payRequest) {
await recordIntermediateFailure({
paymentSessionId: session.payment_session_id,
paymentRequestId: session.payment_request_id,
customerId: session.customer_id,
causeTag,
amount: paySession.amount,
amount: payRequest.amount,
})
}
}
@@ -714,7 +764,7 @@ export const expirePairingRequest = async (sessionId, causeTag = PairingFailureC
await notifyCustomer(session.customer_id, {
type: WsMessage.PAIRING_FAILED,
session_id: sessionId,
payment_session_id: session.payment_session_id,
payment_request_id: session.payment_request_id,
cause_tag: causeTag,
is_terminal: false,
})
@@ -736,7 +786,7 @@ export const expirePairingRequest = async (sessionId, causeTag = PairingFailureC
* Targeted-request timer fired with no mitra response.
*
* INTERMEDIATE failure: the chat_session is marked expired (the targeted attempt is over)
* but the payment_session stays `confirmed` so the customer can fall back to general blast
* but the payment_request stays `confirmed` so the customer can fall back to general blast
* on the same payment, or cancel (which then terminates).
*
* - cause_tag is targeted_mitra_timeout (audit row only)
@@ -747,7 +797,7 @@ export const expireTargetedPairingRequest = async (sessionId) => {
UPDATE chat_sessions
SET status = ${SessionStatus.EXPIRED}
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE}
RETURNING id, customer_id, status, payment_session_id
RETURNING id, customer_id, status, payment_request_id
`
if (!session) return null
@@ -764,15 +814,15 @@ export const expireTargetedPairingRequest = async (sessionId) => {
WHERE session_id = ${sessionId} AND response IS NULL
`
if (session.payment_session_id) {
const paySession = await getPaymentSession(session.payment_session_id)
if (paySession) {
if (session.payment_request_id) {
const payRequest = await getPaymentSession(session.payment_request_id)
if (payRequest) {
await recordIntermediateFailure({
paymentSessionId: session.payment_session_id,
paymentRequestId: session.payment_request_id,
customerId: session.customer_id,
targetedMitraId: notif?.mitra_id ?? null,
causeTag: PairingFailureCause.TARGETED_MITRA_TIMEOUT,
amount: paySession.amount,
amount: payRequest.amount,
})
}
}
@@ -780,7 +830,7 @@ export const expireTargetedPairingRequest = async (sessionId) => {
await notifyCustomer(session.customer_id, {
type: WsMessage.RETURNING_CHAT_TIMEOUT,
session_id: sessionId,
payment_session_id: session.payment_session_id,
payment_request_id: session.payment_request_id,
})
// Notify the targeted mitra that the card is no longer actionable — fan-out in parallel
@@ -798,7 +848,7 @@ export const expireTargetedPairingRequest = async (sessionId) => {
}
export const getPendingRequestsForMitra = async (mitraId) => {
// Distinguish general blast from "Curhat lagi" returning requests via payment_session.targeted_mitra_id.
// Distinguish general blast from "Curhat lagi" returning requests via payment_request.targeted_mitra_id.
// For returning requests, surface the configured timeout so the cold-start (FCM-tap) path can render
// the countdown overlay — same field the WS payload provides for the live path.
const rows = await sql`
@@ -814,7 +864,7 @@ export const getPendingRequestsForMitra = async (mitraId) => {
END AS request_type
FROM chat_request_notifications crn
JOIN chat_sessions cs ON cs.id = crn.session_id
LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id
LEFT JOIN payment_requests ps ON ps.id = cs.payment_request_id
WHERE crn.mitra_id = ${mitraId}
AND crn.response IS NULL
AND cs.status = ${SessionStatus.PENDING_ACCEPTANCE}