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

@@ -1,8 +1,19 @@
import { authenticate } from '../../plugins/auth.js'
import { getCustomerById } from '../../services/customer.service.js'
import { createPairingRequest, cancelPairingRequest } from '../../services/pairing.service.js'
import { getActiveSessionByCustomer, getActiveSessionByCustomerWithUnread, endSession, getCustomerHistory } from '../../services/session.service.js'
import { getPricingForCustomer, isValidTier, isCustomerEligibleForFreeTrial, getFreeTrial } from '../../services/pricing.service.js'
import {
createPairingRequest,
createTargetedPairingRequest,
cancelPairingRequest,
cancelPaymentSearch,
fallbackToGeneralBlast,
} from '../../services/pairing.service.js'
import {
getActiveSessionByCustomer,
getActiveSessionByCustomerWithUnread,
endSession,
getCustomerHistory,
} from '../../services/session.service.js'
import { getPricingForCustomer } from '../../services/pricing.service.js'
import { requestExtension } from '../../services/extension.service.js'
import { EndedBy, TopicSensitivity, UserType } from '../../constants.js'
@@ -30,8 +41,21 @@ export const clientChatRoutes = async (app) => {
return reply.send({ success: true, data: pricing })
})
/**
* Start a general-blast pairing search.
*
* Body MUST include `payment_session_id` (a confirmed payment_session owned by the caller).
* Pricing/duration/free-trial values are sourced from the payment session, NOT from the client.
*/
app.post('/request', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const { duration_minutes, price, is_free_trial, topic_sensitivity } = request.body || {}
const { payment_session_id, topic_sensitivity } = request.body ?? {}
if (!payment_session_id) {
return reply.code(400).send({
success: false,
error: { code: 'BAD_REQUEST', message: 'payment_session_id is required' },
})
}
if (topic_sensitivity !== TopicSensitivity.REGULAR && topic_sensitivity !== TopicSensitivity.SENSITIVE) {
return reply.code(400).send({
@@ -40,43 +64,91 @@ export const clientChatRoutes = async (app) => {
})
}
// Validate selection
if (is_free_trial) {
const eligible = await isCustomerEligibleForFreeTrial(request.customer.id)
if (!eligible) {
return reply.code(403).send({
success: false,
error: { code: 'FREE_TRIAL_INELIGIBLE', message: 'Not eligible for free trial' },
})
}
const freeTrial = await getFreeTrial()
const session = await createPairingRequest(request.customer.id, {
duration_minutes: freeTrial.duration_minutes,
price: 0,
is_free_trial: true,
topic_sensitivity,
})
return reply.code(201).send({ success: true, data: session })
}
if (!duration_minutes || price === undefined) {
return reply.code(400).send({
success: false,
error: { code: 'BAD_REQUEST', message: 'duration_minutes and price are required' },
})
}
if (!(await isValidTier(duration_minutes, price))) {
return reply.code(400).send({
success: false,
error: { code: 'INVALID_TIER', message: 'Invalid price tier selection' },
})
}
const session = await createPairingRequest(request.customer.id, { duration_minutes, price, is_free_trial: false, topic_sensitivity })
const session = await createPairingRequest(request.customer.id, {
paymentSessionId: payment_session_id,
topic_sensitivity,
})
return reply.code(201).send({ success: true, data: session })
})
/**
* Start a targeted "Curhat lagi" pairing request.
*
* Body: { payment_session_id, mitra_id, topic_sensitivity? }
* Returns 409 with reason: 'targeted_mitra_offline' if the targeted mitra is unreachable
* or at capacity. The payment session stays `confirmed` in that case so the customer
* can fall back to general blast on the same payment.
*/
app.post('/chat-requests/returning', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const { payment_session_id, mitra_id, topic_sensitivity } = request.body ?? {}
if (!payment_session_id) {
return reply.code(400).send({
success: false,
error: { code: 'BAD_REQUEST', message: 'payment_session_id is required' },
})
}
if (!mitra_id) {
return reply.code(400).send({
success: false,
error: { code: 'BAD_REQUEST', message: 'mitra_id is required' },
})
}
const resolvedTopic = (topic_sensitivity === TopicSensitivity.SENSITIVE)
? TopicSensitivity.SENSITIVE
: TopicSensitivity.REGULAR
const session = await createTargetedPairingRequest(request.customer.id, {
paymentSessionId: payment_session_id,
targetedMitraId: mitra_id,
topic_sensitivity: resolvedTopic,
})
return reply.code(201).send({ success: true, data: session })
})
/**
* Customer-initiated cancel during searching/waiting.
*
* Body: { payment_session_id }
* Terminal — payment session moves to failed_pairing with cause = customer_cancelled.
*/
app.post('/chat-requests/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const { payment_session_id } = request.body ?? {}
if (!payment_session_id) {
return reply.code(400).send({
success: false,
error: { code: 'BAD_REQUEST', message: 'payment_session_id is required' },
})
}
const result = await cancelPaymentSearch(payment_session_id, request.customer.id)
return reply.send({ success: true, data: result })
})
/**
* After a returning-chat fail, customer taps "Chat dengan bestie lain".
* Reuses the same payment_session_id (no double-charge), runs general blast.
*/
app.post('/chat-requests/:paymentSessionId/fallback-to-blast', {
preHandler: [authenticate, resolveCustomer],
}, async (request, reply) => {
const { topic_sensitivity } = request.body ?? {}
const resolvedTopic = (topic_sensitivity === TopicSensitivity.SENSITIVE)
? TopicSensitivity.SENSITIVE
: TopicSensitivity.REGULAR
const session = await fallbackToGeneralBlast(
request.params.paymentSessionId,
request.customer.id,
{ topic_sensitivity: resolvedTopic },
)
return reply.code(201).send({ success: true, data: session })
})
/**
* Cancel-by-session-id retained for in-flight chat_session cancels (e.g. cancel
* during the 20s targeted wait after a chat_session has been created). Customer cancel
* via payment_session_id should prefer POST /chat-requests/cancel above.
*/
app.post('/request/:sessionId/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await cancelPairingRequest(request.params.sessionId, request.customer.id)
return reply.send({ success: true, data: session })
@@ -97,16 +169,32 @@ export const clientChatRoutes = async (app) => {
return reply.send({ success: true, data: session })
})
// Request session extension
/**
* Extension request REQUIRES `extension_payment_session_id`.
* The payment session must be is_extension=true and is_free_trial=false.
* Pricing/duration come from the payment session via the extension service.
*/
app.post('/session/:sessionId/extend', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const { duration_minutes, price } = request.body || {}
const { duration_minutes, price, extension_payment_session_id } = request.body ?? {}
if (!extension_payment_session_id) {
return reply.code(400).send({
success: false,
error: { code: 'BAD_REQUEST', message: 'extension_payment_session_id is required' },
})
}
if (!duration_minutes || price === undefined) {
return reply.code(400).send({
success: false,
error: { code: 'BAD_REQUEST', message: 'duration_minutes and price are required' },
})
}
const extension = await requestExtension(request.params.sessionId, request.customer.id, { duration_minutes, price })
const extension = await requestExtension(request.params.sessionId, request.customer.id, {
duration_minutes,
price,
extension_payment_session_id,
})
return reply.send({ success: true, data: extension })
})

View File

@@ -0,0 +1,27 @@
import { authenticate } from '../../plugins/auth.js'
import { countAvailableMitrasFromCache } from '../../services/mitra-status.service.js'
import { UserType } from '../../constants.js'
/**
* Customer-home availability poll.
*
* GET /api/client/mitra-availability → 200 { available: bool, count?: number }
*
* Hot endpoint by design — polled every 5s per active customer while their home is
* foregrounded. Backed by a 10s in-memory cache (see mitra-status.service.js) so DB load
* stays bounded regardless of poller count. No rate limit by intent.
*
* `count` is included for CC/debug; the customer UI must read only `available`.
*/
export const clientMitraAvailabilityRoutes = async (app) => {
app.get('/', { preHandler: [authenticate] }, async (request, reply) => {
if (request.auth?.userType !== UserType.CUSTOMER) {
return reply.code(403).send({
success: false,
error: { code: 'FORBIDDEN', message: 'Customer account required' },
})
}
const result = await countAvailableMitrasFromCache()
return reply.send({ success: true, data: result })
})
}

View File

@@ -0,0 +1,155 @@
import { authenticate } from '../../plugins/auth.js'
import { getCustomerById } from '../../services/customer.service.js'
import {
createPaymentSession,
confirmPaymentSession,
abandonPaymentSession,
getPaymentSession,
} from '../../services/payment.service.js'
import {
isCustomerEligibleForFreeTrial,
isValidTier,
getPriceTiers,
} from '../../services/pricing.service.js'
import { UserType } from '../../constants.js'
const resolveCustomer = async (request, reply) => {
if (request.auth?.userType !== UserType.CUSTOMER) {
return reply.code(403).send({
success: false,
error: { code: 'FORBIDDEN', message: 'Customer account required' },
})
}
const customer = await getCustomerById(request.auth.userId)
if (!customer) {
return reply.code(404).send({
success: false,
error: { code: 'ACCOUNT_NOT_FOUND', message: 'Customer account not found' },
})
}
request.customer = customer
}
/**
* Payment session lifecycle (mocked — no Xendit yet).
*
* POST /api/client/payment-sessions
* POST /api/client/payment-sessions/:id/confirm
* POST /api/client/payment-sessions/:id/cancel
* GET /api/client/payment-sessions/:id
*/
export const clientPaymentRoutes = async (app) => {
// Create a payment session (status = pending). Free-trial logic is server-side: if the
// customer is eligible AND this is NOT an extension, amount is forced to 0 and
// is_free_trial = true regardless of what the client passes.
app.post('/', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const {
duration_minutes,
targeted_mitra_id = null,
is_extension = false,
} = request.body ?? {}
if (typeof duration_minutes !== 'number' || duration_minutes <= 0) {
return reply.code(422).send({
success: false,
error: { code: 'VALIDATION_ERROR', message: 'duration_minutes must be a positive number' },
})
}
// Free trial: never for extensions.
let isFreeTrial = false
let amount
if (!is_extension) {
const eligible = await isCustomerEligibleForFreeTrial(request.customer.id)
if (eligible) {
isFreeTrial = true
amount = 0
}
}
if (!isFreeTrial) {
// Resolve amount from the price tiers (duration-keyed). The client passes
// duration_minutes; we look up the matching tier to get the canonical price.
const tiers = await getPriceTiers()
const tier = tiers.find((t) => t.duration_minutes === duration_minutes)
if (!tier) {
return reply.code(400).send({
success: false,
error: { code: 'INVALID_TIER', message: 'No price tier matches the requested duration' },
})
}
amount = tier.price
// Sanity check (defense-in-depth) — duration+price should match a known tier.
if (!(await isValidTier(duration_minutes, amount))) {
return reply.code(400).send({
success: false,
error: { code: 'INVALID_TIER', message: 'Invalid price tier selection' },
})
}
}
const session = await createPaymentSession({
customerId: request.customer.id,
durationMinutes: duration_minutes,
amount,
isFreeTrial,
isExtension: Boolean(is_extension),
targetedMitraId: targeted_mitra_id || null,
})
return reply.code(201).send({
success: true,
data: {
id: session.id,
amount: session.amount,
duration_minutes: session.duration_minutes,
is_free_trial: session.is_free_trial,
is_extension: session.is_extension,
targeted_mitra_id: session.targeted_mitra_id,
expires_at: session.expires_at,
status: session.status,
},
})
})
app.post('/:id/confirm', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await confirmPaymentSession(request.params.id, request.customer.id)
return reply.send({
success: true,
data: {
id: session.id,
status: session.status,
confirmed_at: session.confirmed_at,
},
})
})
app.post('/:id/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await abandonPaymentSession(request.params.id, request.customer.id)
return reply.send({
success: true,
data: {
id: session.id,
status: session.status,
},
})
})
app.get('/:id', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await getPaymentSession(request.params.id)
if (!session) {
return reply.code(404).send({
success: false,
error: { code: 'NOT_FOUND', message: 'Payment session not found' },
})
}
if (session.customer_id !== request.customer.id) {
return reply.code(403).send({
success: false,
error: { code: 'FORBIDDEN', message: 'Payment session does not belong to this customer' },
})
}
return reply.send({ success: true, data: session })
})
}