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:
2026-05-03 23:02:49 +08:00
parent f3766813f3
commit d09e50af55
92 changed files with 9579 additions and 437 deletions

View 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
}