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:
@@ -7,11 +7,14 @@ import {
|
||||
getPaymentSession,
|
||||
} from '../../services/payment.service.js'
|
||||
import {
|
||||
isCustomerEligibleForFreeTrial,
|
||||
isCustomerEligibleForFirstSessionDiscount,
|
||||
isValidTier,
|
||||
getPriceTiers,
|
||||
findTier,
|
||||
} from '../../services/pricing.service.js'
|
||||
import { UserType } from '../../constants.js'
|
||||
import { getDb } from '../../db/client.js'
|
||||
import { UserType, SessionMode } from '../../constants.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
const resolveCustomer = async (request, reply) => {
|
||||
if (request.auth?.userType !== UserType.CUSTOMER) {
|
||||
@@ -30,6 +33,25 @@ const resolveCustomer = async (request, reply) => {
|
||||
request.customer = customer
|
||||
}
|
||||
|
||||
const readDiscountConfig = async () => {
|
||||
const rows = await sql`
|
||||
SELECT key, value FROM app_config
|
||||
WHERE key IN (
|
||||
'first_session_discount_enabled',
|
||||
'first_session_discount_actual_price_idr',
|
||||
'first_session_discount_duration_minutes',
|
||||
'first_session_discount_modes'
|
||||
)
|
||||
`
|
||||
const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value?.value]))
|
||||
return {
|
||||
enabled: byKey.first_session_discount_enabled ?? true,
|
||||
actual_price_idr: byKey.first_session_discount_actual_price_idr ?? 2000,
|
||||
duration_minutes: byKey.first_session_discount_duration_minutes ?? 12,
|
||||
modes: byKey.first_session_discount_modes ?? ['chat'],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment session lifecycle (mocked — no Xendit yet).
|
||||
*
|
||||
@@ -39,12 +61,13 @@ const resolveCustomer = async (request, reply) => {
|
||||
* 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.
|
||||
// Create a payment session (status = pending). First-session-discount is server-authoritative:
|
||||
// if the customer is eligible AND this is NOT an extension AND mode is in the configured
|
||||
// modes list, amount is forced to the configured discount price.
|
||||
app.post('/', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||
const {
|
||||
duration_minutes,
|
||||
mode = SessionMode.CHAT,
|
||||
targeted_mitra_id = null,
|
||||
is_extension = false,
|
||||
} = request.body ?? {}
|
||||
@@ -55,33 +78,44 @@ export const clientPaymentRoutes = async (app) => {
|
||||
error: { code: 'VALIDATION_ERROR', message: 'duration_minutes must be a positive number' },
|
||||
})
|
||||
}
|
||||
if (mode !== SessionMode.CHAT && mode !== SessionMode.CALL) {
|
||||
return reply.code(422).send({
|
||||
success: false,
|
||||
error: { code: 'VALIDATION_ERROR', message: 'mode must be chat or call' },
|
||||
})
|
||||
}
|
||||
|
||||
// Free trial: never for extensions.
|
||||
let isFreeTrial = false
|
||||
let isFirstSessionDiscount = false
|
||||
let amount
|
||||
|
||||
if (!is_extension) {
|
||||
const eligible = await isCustomerEligibleForFreeTrial(request.customer.id)
|
||||
const eligible = await isCustomerEligibleForFirstSessionDiscount(request.customer.id)
|
||||
if (eligible) {
|
||||
isFreeTrial = true
|
||||
amount = 0
|
||||
const discount = await readDiscountConfig()
|
||||
// Discount is mode-gated. With default config (modes: ['chat']) call-mode never
|
||||
// gets the discount even if the user is eligible.
|
||||
if (
|
||||
discount.enabled
|
||||
&& discount.modes.includes(mode)
|
||||
&& duration_minutes === discount.duration_minutes
|
||||
) {
|
||||
isFirstSessionDiscount = true
|
||||
amount = discount.actual_price_idr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 (!isFirstSessionDiscount) {
|
||||
// Resolve amount from the configured tier list for the requested mode.
|
||||
const tier = await findTier({ mode, durationMinutes: duration_minutes })
|
||||
if (!tier) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
error: { code: 'INVALID_TIER', message: 'No price tier matches the requested duration' },
|
||||
error: { code: 'INVALID_TIER', message: 'No price tier matches the requested mode/duration' },
|
||||
})
|
||||
}
|
||||
amount = tier.price
|
||||
// Sanity check (defense-in-depth) — duration+price should match a known tier.
|
||||
if (!(await isValidTier(duration_minutes, amount))) {
|
||||
amount = tier.price_idr
|
||||
if (!(await isValidTier({ mode, durationMinutes: duration_minutes, priceIdr: amount }))) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
error: { code: 'INVALID_TIER', message: 'Invalid price tier selection' },
|
||||
@@ -93,9 +127,10 @@ export const clientPaymentRoutes = async (app) => {
|
||||
customerId: request.customer.id,
|
||||
durationMinutes: duration_minutes,
|
||||
amount,
|
||||
isFreeTrial,
|
||||
isFirstSessionDiscount,
|
||||
isExtension: Boolean(is_extension),
|
||||
targetedMitraId: targeted_mitra_id || null,
|
||||
mode,
|
||||
})
|
||||
|
||||
return reply.code(201).send({
|
||||
@@ -104,8 +139,9 @@ export const clientPaymentRoutes = async (app) => {
|
||||
id: session.id,
|
||||
amount: session.amount,
|
||||
duration_minutes: session.duration_minutes,
|
||||
is_free_trial: session.is_free_trial,
|
||||
is_first_session_discount: session.is_first_session_discount,
|
||||
is_extension: session.is_extension,
|
||||
mode: session.mode,
|
||||
targeted_mitra_id: session.targeted_mitra_id,
|
||||
expires_at: session.expires_at,
|
||||
status: session.status,
|
||||
|
||||
Reference in New Issue
Block a user