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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user