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:
155
backend/src/routes/public/client.payment.routes.js
Normal file
155
backend/src/routes/public/client.payment.routes.js
Normal 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 })
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user