Phase 3.7: paid pairing flow + returning chat + extension flip
- Backend: payment_sessions + pairing_failures tables; payment.service.js and pairing-failure.service.js (new); rewritten pairing.service.js (payment-gated blast + targeted "Curhat lagi" + cancel + fallback); rewritten extension.service.js (data-driven auto-approve with offline safeguard, charge-at-approval); pricing.service.js (extension tiers without free trial); mitra-status.service.js (countAvailableMitras cached path); 60s sweeper for stale payment sessions - Backend routes: client.payment.routes, client.mitra-availability.routes, internal/failed-pairings.routes; client.chat.routes rewritten for payment-gated start + /returning + /cancel + /fallback-to-blast; internal/config.routes adds 4 new keys with Valkey invalidate publish - client_app: mitra-availability poll, payment screen + notifier, pairing notifier rewrite (PairingTargetedWaiting + PairingFailed states), targeted-waiting overlay + bestie-unavailable dialog, "Curhat lagi" CTA, failed-pairing terminal, extension via payment-session - mitra_app: PairingRequestType enum, returning-chat 20s countdown auto-dismiss, extension card "otomatis disetujui" copy - control_center: 4 new config rows in Settings, Failed Pairings page (filter + paginate + action menu), sidebar + route registered - Test infrastructure: Vitest backend (7/7 pass), Playwright CC (4/4 pass), Maestro mobile scaffold (CLI install pending) - Bugs found via Playwright + fixed: LoginPage labels not associated with inputs (a11y); backend internal CORS missing PATCH/PUT/DELETE in allow-methods (silent settings breakage in browsers since Stage 4) - Docs: phase3.7.md PRD, phase3.7-plan.md, phase3.7-questions.md (Q&A), phase3.7-testing.md (E2E checklist), phase3.7-test-run-2026-05-03.md (today's run results) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
298
backend/src/services/payment.service.js
Normal file
298
backend/src/services/payment.service.js
Normal file
@@ -0,0 +1,298 @@
|
||||
import { getDb } from '../db/client.js'
|
||||
import { PaymentSessionStatus, PairingFailureCause, UserType, WsMessage } from '../constants.js'
|
||||
import { recordFailure } from './pairing-failure.service.js'
|
||||
import { sendToUser } from '../plugins/websocket.js'
|
||||
import { sendPushNotification } from './notification.service.js'
|
||||
import { getPaymentSessionTimeoutMinutes as readPaymentSessionTimeoutMinutes } from './config.service.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
const getPaymentSessionTimeoutMinutes = async () => {
|
||||
const { payment_session_timeout_minutes } = await readPaymentSessionTimeoutMinutes()
|
||||
return payment_session_timeout_minutes
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new payment session in `pending` status.
|
||||
* Reads `payment_session_timeout_minutes` from config to compute expires_at.
|
||||
*/
|
||||
export const createPaymentSession = async ({
|
||||
customerId,
|
||||
durationMinutes,
|
||||
amount,
|
||||
isFreeTrial = false,
|
||||
isExtension = false,
|
||||
targetedMitraId = null,
|
||||
}) => {
|
||||
if (!customerId) {
|
||||
throw Object.assign(new Error('customerId is required'), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
||||
}
|
||||
if (typeof durationMinutes !== 'number' || durationMinutes <= 0) {
|
||||
throw Object.assign(new Error('durationMinutes must be a positive number'), { 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 })
|
||||
}
|
||||
|
||||
const ttlMinutes = await getPaymentSessionTimeoutMinutes()
|
||||
|
||||
const [row] = await sql`
|
||||
INSERT INTO payment_sessions (
|
||||
customer_id, amount, duration_minutes, is_free_trial, is_extension,
|
||||
status, targeted_mitra_id, expires_at
|
||||
)
|
||||
VALUES (
|
||||
${customerId}, ${amount}, ${durationMinutes}, ${isFreeTrial}, ${isExtension},
|
||||
${PaymentSessionStatus.PENDING}, ${targetedMitraId},
|
||||
NOW() + (${ttlMinutes} || ' minutes')::interval
|
||||
)
|
||||
RETURNING id, customer_id, amount, duration_minutes, is_free_trial, is_extension,
|
||||
status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
|
||||
`
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition pending → confirmed. Throws on ownership/status/expiry mismatch.
|
||||
*/
|
||||
export const confirmPaymentSession = async (paymentSessionId, customerId) => {
|
||||
const [existing] = await sql`
|
||||
SELECT id, customer_id, status, expires_at
|
||||
FROM payment_sessions
|
||||
WHERE id = ${paymentSessionId}
|
||||
`
|
||||
if (!existing) {
|
||||
throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 })
|
||||
}
|
||||
if (existing.customer_id !== customerId) {
|
||||
throw Object.assign(new Error('Payment session does not belong to this customer'), { code: 'FORBIDDEN', statusCode: 403 })
|
||||
}
|
||||
if (existing.status !== PaymentSessionStatus.PENDING) {
|
||||
throw Object.assign(new Error(`Payment session is ${existing.status}, cannot confirm`), {
|
||||
code: 'INVALID_STATE', statusCode: 409,
|
||||
})
|
||||
}
|
||||
if (new Date(existing.expires_at) <= new Date()) {
|
||||
// Inline expiry check in addition to the background sweeper, since the customer can
|
||||
// attempt to confirm a row that's already past expires_at before the sweep runs.
|
||||
await sql`
|
||||
UPDATE payment_sessions SET status = ${PaymentSessionStatus.EXPIRED}
|
||||
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.PENDING}
|
||||
`
|
||||
throw Object.assign(new Error('Payment session has expired'), { code: 'EXPIRED', statusCode: 409 })
|
||||
}
|
||||
|
||||
const [updated] = await sql`
|
||||
UPDATE payment_sessions
|
||||
SET status = ${PaymentSessionStatus.CONFIRMED}, confirmed_at = NOW()
|
||||
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.PENDING}
|
||||
RETURNING id, customer_id, amount, duration_minutes, is_free_trial, is_extension,
|
||||
status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
|
||||
`
|
||||
if (!updated) {
|
||||
throw Object.assign(new Error('Payment session state changed during confirm'), { code: 'CONFLICT', statusCode: 409 })
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition confirmed → consumed. Called from pairing service when a chat starts.
|
||||
* Idempotent at higher level (caller should check status first if it matters).
|
||||
*/
|
||||
export const consumePaymentSession = async (paymentSessionId) => {
|
||||
const [updated] = await sql`
|
||||
UPDATE payment_sessions
|
||||
SET status = ${PaymentSessionStatus.CONSUMED}, consumed_at = NOW()
|
||||
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.CONFIRMED}
|
||||
RETURNING id, status, consumed_at
|
||||
`
|
||||
return updated || null
|
||||
}
|
||||
|
||||
/**
|
||||
* TERMINAL: mark a confirmed payment session as failed_pairing AND write a pairing_failures row.
|
||||
* Idempotent: no-op if already terminal (consumed/failed_pairing/expired/abandoned).
|
||||
*
|
||||
* Use only for true terminal failures (no fallback path possible):
|
||||
* - general blast exhausted, no acceptance
|
||||
* - all blasted mitras explicitly rejected
|
||||
* - customer cancels mid-search
|
||||
* - payment session expires before consumption
|
||||
*
|
||||
* For intermediate failures that have a fallback CTA available (targeted-mitra reject/timeout
|
||||
* during a returning-chat flow), use `recordIntermediateFailure` instead — that writes the
|
||||
* audit row WITHOUT terminating the payment session. Termination is the caller's decision
|
||||
* (cancel CTA = terminal, fallback-to-blast CTA = stays confirmed).
|
||||
*/
|
||||
export const failPaymentSession = async (paymentSessionId, 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
|
||||
FROM payment_sessions
|
||||
WHERE id = ${paymentSessionId}
|
||||
`
|
||||
if (!existing) {
|
||||
return null
|
||||
}
|
||||
// Idempotent: only confirmed sessions transition to failed_pairing here.
|
||||
// Pending sessions become expired/abandoned via their own paths.
|
||||
if (existing.status !== PaymentSessionStatus.CONFIRMED) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const [updated] = await sql`
|
||||
UPDATE payment_sessions
|
||||
SET status = ${PaymentSessionStatus.FAILED_PAIRING}
|
||||
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.CONFIRMED}
|
||||
RETURNING id, customer_id, targeted_mitra_id, amount, status
|
||||
`
|
||||
if (!updated) {
|
||||
return existing
|
||||
}
|
||||
|
||||
await recordFailure({
|
||||
paymentSessionId,
|
||||
customerId: existing.customer_id,
|
||||
targetedMitraId: existing.targeted_mitra_id,
|
||||
causeTag,
|
||||
amount: existing.amount,
|
||||
})
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* INTERMEDIATE: write a pairing_failures audit row WITHOUT terminating the payment session.
|
||||
*
|
||||
* Used for failures inside a flow that still has a fallback path: targeted "Curhat lagi"
|
||||
* reject/timeout (customer can fall back to general blast on the same payment), or any
|
||||
* other intermediate attempt where the payment_session must remain `confirmed` so it can be
|
||||
* reused.
|
||||
*
|
||||
* One payment_session may FK from multiple pairing_failures rows — that's the desired CC
|
||||
* UX (each attempt lists as its own row in the Failed Pairings table).
|
||||
*
|
||||
* Returns the inserted pairing_failures row, or null if the payment session was missing.
|
||||
*/
|
||||
export const recordIntermediateFailure = async ({ paymentSessionId, 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 })
|
||||
}
|
||||
|
||||
// Loose sanity: the row should exist. If not, fall through with null — caller likely
|
||||
// already moved on; we'd rather skip the audit than throw mid-callback.
|
||||
const [existing] = await sql`SELECT id FROM payment_sessions WHERE id = ${paymentSessionId}`
|
||||
if (!existing) return null
|
||||
|
||||
return recordFailure({
|
||||
paymentSessionId,
|
||||
customerId,
|
||||
targetedMitraId,
|
||||
causeTag,
|
||||
amount,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer-initiated abandonment of a `pending` payment session (e.g. closed payment screen).
|
||||
* No pairing_failures row is written — only confirmed-but-no-chat counts as a pairing failure.
|
||||
*/
|
||||
export const abandonPaymentSession = async (paymentSessionId, customerId) => {
|
||||
const [existing] = await sql`
|
||||
SELECT id, customer_id, status FROM payment_sessions WHERE id = ${paymentSessionId}
|
||||
`
|
||||
if (!existing) {
|
||||
throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 })
|
||||
}
|
||||
if (existing.customer_id !== customerId) {
|
||||
throw Object.assign(new Error('Payment session does not belong to this customer'), { code: 'FORBIDDEN', statusCode: 403 })
|
||||
}
|
||||
if (existing.status !== PaymentSessionStatus.PENDING) {
|
||||
// Idempotent — already terminal.
|
||||
return existing
|
||||
}
|
||||
const [updated] = await sql`
|
||||
UPDATE payment_sessions SET status = ${PaymentSessionStatus.ABANDONED}
|
||||
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.PENDING}
|
||||
RETURNING id, customer_id, status
|
||||
`
|
||||
return updated || existing
|
||||
}
|
||||
|
||||
/**
|
||||
* Background sweeper:
|
||||
* - pending rows past expires_at → expired (no failure row; never confirmed)
|
||||
* - confirmed rows past expires_at AND not consumed → failed_pairing with cause = payment_session_expired
|
||||
*/
|
||||
export const expireStalePaymentSessions = async () => {
|
||||
// 1) pending → expired
|
||||
const expired = await sql`
|
||||
UPDATE payment_sessions
|
||||
SET status = ${PaymentSessionStatus.EXPIRED}
|
||||
WHERE status = ${PaymentSessionStatus.PENDING}
|
||||
AND expires_at <= NOW()
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
// 2) confirmed-but-stale → failed_pairing. Single atomic UPDATE returns the rows we
|
||||
// actually flipped (vs. the old SELECT + per-row UPDATE which leaked a TOCTOU window
|
||||
// with concurrent confirmPaymentSession/consumePaymentSession). Audit-row writes and
|
||||
// customer notifications then fan out in parallel.
|
||||
const flipped = await sql`
|
||||
UPDATE payment_sessions
|
||||
SET status = ${PaymentSessionStatus.FAILED_PAIRING}
|
||||
WHERE status = ${PaymentSessionStatus.CONFIRMED}
|
||||
AND expires_at <= NOW()
|
||||
RETURNING id, customer_id, targeted_mitra_id, amount
|
||||
`
|
||||
|
||||
await Promise.all(flipped.map(async (row) => {
|
||||
await recordFailure({
|
||||
paymentSessionId: row.id,
|
||||
customerId: row.customer_id,
|
||||
targetedMitraId: row.targeted_mitra_id,
|
||||
causeTag: PairingFailureCause.PAYMENT_SESSION_EXPIRED,
|
||||
amount: row.amount,
|
||||
})
|
||||
// Customer may be on searching/waiting; push terminal PAIRING_FAILED in real time.
|
||||
// FCM fallback when not WS-connected so they're notified at the OS level.
|
||||
try {
|
||||
const wsSent = sendToUser(UserType.CUSTOMER, row.customer_id, {
|
||||
type: WsMessage.PAIRING_FAILED,
|
||||
payment_session_id: row.id,
|
||||
cause_tag: PairingFailureCause.PAYMENT_SESSION_EXPIRED,
|
||||
})
|
||||
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_session_id: row.id,
|
||||
cause_tag: PairingFailureCause.PAYMENT_SESSION_EXPIRED,
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('expireStalePaymentSessions: failed to notify customer', {
|
||||
paymentSessionId: row.id, customerId: row.customer_id, err,
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
return { expired: expired.length, failed: flipped.length }
|
||||
}
|
||||
|
||||
export const getPaymentSession = async (id) => {
|
||||
const [row] = await sql`
|
||||
SELECT id, customer_id, amount, duration_minutes, is_free_trial, is_extension,
|
||||
status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
|
||||
FROM payment_sessions
|
||||
WHERE id = ${id}
|
||||
`
|
||||
return row || null
|
||||
}
|
||||
Reference in New Issue
Block a user