Phase 4 Stage 1: backend foundation (additive endpoints + schema)

Schema (idempotent migration):
- payment_sessions.is_free_trial -> is_first_session_discount (data copied)
- payment_sessions.mode TEXT NOT NULL DEFAULT 'chat' CHECK (chat|call)
- chat_sessions.topics TEXT[] for ESP picks (info-only)

New endpoints:
- GET /api/client/onboarding-state (drives verif sheet + S6 paywall gate)
- GET /api/client/chat-pricing (rewrite: chat+call groups + first-session
  discount block, per-customer eligibility)
- GET /api/shared/auth-providers (env-probed; replaces ENABLE_SOCIAL_AUTH
  build flag — frontend cutover lands in stage 2)
- GET /api/client/support-handles (Tanya Admin handles, CC-config-driven)

session_warning WS event fires once at 180s remaining.

app_config seeds (mock pricing tiers, first-session discount, support
handles, payment method order, end-session 2-step toggle).

CC SettingsPage: 3 new sections (first-session discount, pricing tiers
JSON editors, support handles).

15/15 Vitest passing. chat_sessions.is_free_trial also renamed for
consistency (plan only specified payment_sessions; pairing.service.js
read both).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 15:56:28 +08:00
parent 4ada7c991a
commit d33d4419ea
24 changed files with 1347 additions and 162 deletions

View File

@@ -1,5 +1,5 @@
import { getDb } from '../db/client.js'
import { PaymentSessionStatus, PairingFailureCause, UserType, WsMessage } from '../constants.js'
import { PaymentSessionStatus, PairingFailureCause, UserType, WsMessage, SessionMode } from '../constants.js'
import { recordFailure } from './pairing-failure.service.js'
import { sendToUser } from '../plugins/websocket.js'
import { sendPushNotification } from './notification.service.js'
@@ -15,14 +15,19 @@ const getPaymentSessionTimeoutMinutes = async () => {
/**
* Create a new payment session in `pending` status.
* Reads `payment_session_timeout_minutes` from config to compute expires_at.
*
* Phase 4: `isFirstSessionDiscount` replaces the old `isFreeTrial` flag. Voice-call
* mode is a routing/badge thing — the price comes from the call tier group, not from
* the mode itself.
*/
export const createPaymentSession = async ({
customerId,
durationMinutes,
amount,
isFreeTrial = false,
isFirstSessionDiscount = false,
isExtension = false,
targetedMitraId = null,
mode = SessionMode.CHAT,
}) => {
if (!customerId) {
throw Object.assign(new Error('customerId is required'), { code: 'VALIDATION_ERROR', statusCode: 422 })
@@ -33,21 +38,24 @@ export const createPaymentSession = async ({
if (typeof amount !== 'number' || amount < 0) {
throw Object.assign(new Error('amount must be a non-negative number'), { code: 'VALIDATION_ERROR', statusCode: 422 })
}
if (mode !== SessionMode.CHAT && mode !== SessionMode.CALL) {
throw Object.assign(new Error('mode must be chat or call'), { 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
customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
status, targeted_mitra_id, mode, expires_at
)
VALUES (
${customerId}, ${amount}, ${durationMinutes}, ${isFreeTrial}, ${isExtension},
${PaymentSessionStatus.PENDING}, ${targetedMitraId},
${customerId}, ${amount}, ${durationMinutes}, ${isFirstSessionDiscount}, ${isExtension},
${PaymentSessionStatus.PENDING}, ${targetedMitraId}, ${mode},
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
RETURNING id, customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
mode, status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
`
return row
@@ -87,8 +95,8 @@ export const confirmPaymentSession = async (paymentSessionId, customerId) => {
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
RETURNING id, customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
mode, 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 })
@@ -289,8 +297,8 @@ export const expireStalePaymentSessions = async () => {
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
SELECT id, customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
mode, status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
FROM payment_sessions
WHERE id = ${id}
`