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:
@@ -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}
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user