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,75 +1,175 @@
|
||||
import { getDb } from '../db/client.js'
|
||||
import { SessionStatus } from '../constants.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
// Default tiers as fallback
|
||||
const DEFAULT_TIERS = [
|
||||
{ duration_minutes: 1, price: 5000, label: '1 Menit (Test)' },
|
||||
{ duration_minutes: 15, price: 30000, label: '15 Menit' },
|
||||
{ duration_minutes: 30, price: 60000, label: '30 Menit' },
|
||||
{ duration_minutes: 45, price: 100000, label: '45 Menit' },
|
||||
{ duration_minutes: 60, price: 150000, label: '60 Menit' },
|
||||
{ duration_minutes: 1440, price: 250000, label: '24 Jam' },
|
||||
// Default tiers as fallback (used if app_config row is missing). Match the seed
|
||||
// values in migrate.js so a missing row never breaks pricing in the wild.
|
||||
const DEFAULT_CHAT_TIERS = [
|
||||
{ id: '5', minutes: 5, price_idr: 5000, tag: null },
|
||||
{ id: '12', minutes: 12, price_idr: 12000, tag: 'paling pas' },
|
||||
{ id: '30', minutes: 30, price_idr: 25000, tag: 'hemat' },
|
||||
{ id: '60', minutes: 60, price_idr: 45000, tag: null },
|
||||
{ id: '120', minutes: 120, price_idr: 80000, tag: 'best deal' },
|
||||
]
|
||||
|
||||
export const getPriceTiers = async () => {
|
||||
const [row] = await sql`SELECT value FROM app_config WHERE key = 'price_tiers'`
|
||||
return row?.value?.tiers ?? DEFAULT_TIERS
|
||||
const DEFAULT_CALL_TIERS = [
|
||||
{ id: '10', minutes: 10, price_idr: 9000, tag: null },
|
||||
{ id: '20', minutes: 20, price_idr: 17000, tag: 'paling pas' },
|
||||
{ id: '45', minutes: 45, price_idr: 35000, tag: null },
|
||||
{ id: '60', minutes: 60, price_idr: 45000, tag: 'hemat' },
|
||||
]
|
||||
const DEFAULT_DISCOUNT = {
|
||||
enabled: true,
|
||||
actual_price_idr: 2000,
|
||||
gimmick_price_idr: 12000,
|
||||
duration_minutes: 12,
|
||||
modes: ['chat'],
|
||||
}
|
||||
|
||||
export const isValidTier = async (durationMinutes, price) => {
|
||||
const tiers = await getPriceTiers()
|
||||
return tiers.some(
|
||||
(t) => t.duration_minutes === durationMinutes && t.price === price
|
||||
)
|
||||
const readChatTiers = async () => {
|
||||
const [row] = await sql`SELECT value FROM app_config WHERE key = 'pricing_chat_tiers_json'`
|
||||
return row?.value?.tiers ?? DEFAULT_CHAT_TIERS
|
||||
}
|
||||
|
||||
export const getFreeTrial = async () => {
|
||||
const [enabledRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_enabled'`
|
||||
const [durationRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_duration_minutes'`
|
||||
return {
|
||||
enabled: enabledRow?.value?.value ?? false,
|
||||
duration_minutes: durationRow?.value?.value ?? 5,
|
||||
}
|
||||
const readCallTiers = async () => {
|
||||
const [row] = await sql`SELECT value FROM app_config WHERE key = 'pricing_call_tiers_json'`
|
||||
return row?.value?.tiers ?? DEFAULT_CALL_TIERS
|
||||
}
|
||||
|
||||
export const isCustomerEligibleForFreeTrial = async (customerId) => {
|
||||
const freeTrial = await getFreeTrial()
|
||||
if (!freeTrial.enabled) return false
|
||||
|
||||
const [tx] = await sql`
|
||||
SELECT id FROM customer_transactions
|
||||
WHERE customer_id = ${customerId}
|
||||
LIMIT 1
|
||||
const readDiscountConfig = async () => {
|
||||
const keys = [
|
||||
'first_session_discount_enabled',
|
||||
'first_session_discount_actual_price_idr',
|
||||
'first_session_discount_gimmick_price_idr',
|
||||
'first_session_discount_duration_minutes',
|
||||
'first_session_discount_modes',
|
||||
]
|
||||
const rows = await sql`
|
||||
SELECT key, value FROM app_config WHERE key IN ${sql(keys)}
|
||||
`
|
||||
return !tx // Eligible only if no transactions at all
|
||||
}
|
||||
|
||||
export const getPricingForCustomer = async (customerId) => {
|
||||
const tiers = await getPriceTiers()
|
||||
const freeTrialEligible = await isCustomerEligibleForFreeTrial(customerId)
|
||||
const freeTrial = await getFreeTrial()
|
||||
|
||||
const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value?.value]))
|
||||
return {
|
||||
tiers,
|
||||
free_trial: freeTrialEligible
|
||||
? { eligible: true, duration_minutes: freeTrial.duration_minutes }
|
||||
: { eligible: false },
|
||||
enabled: byKey.first_session_discount_enabled ?? DEFAULT_DISCOUNT.enabled,
|
||||
actual_price_idr: byKey.first_session_discount_actual_price_idr ?? DEFAULT_DISCOUNT.actual_price_idr,
|
||||
gimmick_price_idr: byKey.first_session_discount_gimmick_price_idr ?? DEFAULT_DISCOUNT.gimmick_price_idr,
|
||||
duration_minutes: byKey.first_session_discount_duration_minutes ?? DEFAULT_DISCOUNT.duration_minutes,
|
||||
modes: byKey.first_session_discount_modes ?? DEFAULT_DISCOUNT.modes,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension pricing tiers.
|
||||
* Per-customer first-session-discount eligibility.
|
||||
*
|
||||
* Same shape as `getPricingForCustomer`, but free trial is NEVER eligible for extensions.
|
||||
* The customerId is accepted for API symmetry/future tier personalization.
|
||||
* Predicate (Phase 4):
|
||||
* - app_config.first_session_discount_enabled == true, AND
|
||||
* - customer is phone-verified (customers.phone IS NOT NULL — phone only gets set
|
||||
* via the OTP-verify path, so non-null is proof of verification), AND
|
||||
* - customer has no completed/closing chat_sessions row (returning users pay full price).
|
||||
*
|
||||
* Note: deviates from the plan's `users.phone_verified_at` reference — there is no such
|
||||
* column. `phone IS NOT NULL` is the equivalent invariant in this schema.
|
||||
*/
|
||||
export const isCustomerEligibleForFirstSessionDiscount = async (customerId) => {
|
||||
const discount = await readDiscountConfig()
|
||||
if (!discount.enabled) return false
|
||||
|
||||
const [customer] = await sql`
|
||||
SELECT phone FROM customers WHERE id = ${customerId}
|
||||
`
|
||||
if (!customer || !customer.phone) return false
|
||||
|
||||
const [prior] = await sql`
|
||||
SELECT id FROM chat_sessions
|
||||
WHERE customer_id = ${customerId}
|
||||
AND status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
|
||||
LIMIT 1
|
||||
`
|
||||
return !prior
|
||||
}
|
||||
|
||||
/**
|
||||
* Pricing payload for the client. Returns chat + call tier groups plus a per-customer
|
||||
* first-session-discount block.
|
||||
*
|
||||
* Shape:
|
||||
* { chat: { tiers: [...] },
|
||||
* call: { tiers: [...] },
|
||||
* first_session_discount: {
|
||||
* eligible: boolean,
|
||||
* actual_price_idr, gimmick_price_idr, duration_minutes, modes: string[]
|
||||
* } }
|
||||
*/
|
||||
export const getPricingForCustomer = async (customerId) => {
|
||||
const [chatTiers, callTiers, discount, eligible] = await Promise.all([
|
||||
readChatTiers(),
|
||||
readCallTiers(),
|
||||
readDiscountConfig(),
|
||||
isCustomerEligibleForFirstSessionDiscount(customerId),
|
||||
])
|
||||
return {
|
||||
chat: { tiers: chatTiers },
|
||||
call: { tiers: callTiers },
|
||||
first_session_discount: {
|
||||
eligible,
|
||||
actual_price_idr: discount.actual_price_idr,
|
||||
gimmick_price_idr: discount.gimmick_price_idr,
|
||||
duration_minutes: discount.duration_minutes,
|
||||
modes: discount.modes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a (mode, duration_minutes, price_idr) selection against the configured tiers.
|
||||
* Used by payment-session creation as a defense-in-depth check.
|
||||
*/
|
||||
export const isValidTier = async ({ mode, durationMinutes, priceIdr }) => {
|
||||
const tiers = mode === 'call' ? await readCallTiers() : await readChatTiers()
|
||||
return tiers.some((t) => t.minutes === durationMinutes && t.price_idr === priceIdr)
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the canonical tier for (mode, duration_minutes). Returns null if no match.
|
||||
*/
|
||||
export const findTier = async ({ mode, durationMinutes }) => {
|
||||
const tiers = mode === 'call' ? await readCallTiers() : await readChatTiers()
|
||||
return tiers.find((t) => t.minutes === durationMinutes) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension pricing — same chat tiers, but first-session discount NEVER applies.
|
||||
* (Kept for parity with the old pricing.service shape; voice-call extensions are not
|
||||
* a current feature, so we return chat tiers only.)
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
export const getExtensionPriceTiers = async (customerId) => {
|
||||
const tiers = await getPriceTiers()
|
||||
const tiers = await readChatTiers()
|
||||
return {
|
||||
tiers,
|
||||
free_trial: { eligible: false },
|
||||
is_free_trial: false,
|
||||
first_session_discount: { eligible: false },
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Back-compat aliases (will be removed after Phase 4 frontend cutover) ----
|
||||
|
||||
/**
|
||||
* @deprecated Use isCustomerEligibleForFirstSessionDiscount.
|
||||
* Kept so route handlers and migrated services still resolve while we cut over.
|
||||
*/
|
||||
export const isCustomerEligibleForFreeTrial = isCustomerEligibleForFirstSessionDiscount
|
||||
|
||||
/**
|
||||
* @deprecated Tiers now live in `chat`/`call` groups; callers should pick one.
|
||||
* Returns chat tiers in the legacy shape (single array, no group wrapper).
|
||||
*/
|
||||
export const getPriceTiers = async () => {
|
||||
const tiers = await readChatTiers()
|
||||
// Legacy callers expected `{duration_minutes, price, label}` keys. Map.
|
||||
return tiers.map((t) => ({
|
||||
duration_minutes: t.minutes,
|
||||
price: t.price_idr,
|
||||
label: `${t.minutes} Menit`,
|
||||
id: t.id,
|
||||
tag: t.tag,
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user