import { getDb } from '../db/client.js' import { SessionStatus } from '../constants.js' const sql = getDb() // Default tiers as fallback (used if pricing_tiers is empty — e.g. fresh dev DB or // test fixture without backfill). Match the DEFAULT_*_BACKFILL arrays in migrate.js // so a missing row never breaks pricing in the wild. Stage 3 keeps these as a // belt-and-suspenders only — `id` here is the legacy bare string, which Stage 3 // preserves for the "no rows" edge case so the customer-facing shape (UUID strings // once populated, bare strings only in this degraded fallback path) keeps the // same JSON keys. 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' }, ] 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'], } // Map a pricing_tiers row to the customer-facing tier object. `original_price_idr` // is deliberately omitted from the public shape (Stage 3 keeps it schema-only). const toCustomerTier = (row) => ({ id: row.id, tag: row.tag, minutes: row.minutes, price_idr: row.price_idr, }) const readChatTiers = async () => { const rows = await sql` SELECT id, minutes, price_idr, tag, sort_order FROM pricing_tiers WHERE mode = 'chat' AND is_active = true ORDER BY sort_order ASC, minutes ASC ` if (rows.length === 0) return DEFAULT_CHAT_TIERS return rows.map(toCustomerTier) } const readCallTiers = async () => { const rows = await sql` SELECT id, minutes, price_idr, tag, sort_order FROM pricing_tiers WHERE mode = 'call' AND is_active = true ORDER BY sort_order ASC, minutes ASC ` if (rows.length === 0) return DEFAULT_CALL_TIERS return rows.map(toCustomerTier) } /** * Read the canonical first-session-discount config from `pricing_promotions` * (eligibility = 'first_session'). Falls back to DEFAULT_DISCOUNT if the row * is missing (fresh dev DB or partial migration). * * Exported because the customer-facing payment-session route needs the exact * same source-of-truth as `getPricingForCustomer` and the eligibility predicate * — duplicating the read in route code would be a foot-gun for operator edits * in CC (Stage 5 moved this off `app_config` JSON to the relational table). */ export const readFirstSessionDiscountConfig = async () => { const [row] = await sql` SELECT enabled, actual_price_idr, gimmick_price_idr, duration_minutes, modes FROM pricing_promotions WHERE eligibility = 'first_session' ` if (!row) return DEFAULT_DISCOUNT return { enabled: row.enabled, actual_price_idr: row.actual_price_idr, gimmick_price_idr: row.gimmick_price_idr, duration_minutes: row.duration_minutes, modes: row.modes, } } /** * Per-customer first-session-discount eligibility. * * Predicate (Phase 4): * - pricing_promotions.enabled (for eligibility='first_session') == 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 readFirstSessionDiscountConfig() 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(), readFirstSessionDiscountConfig(), 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 [row] = await sql` SELECT id FROM pricing_tiers WHERE mode = ${mode} AND minutes = ${durationMinutes} AND price_idr = ${priceIdr} AND is_active = true LIMIT 1 ` if (row) return true // Fallback: empty table → use in-memory defaults. Keeps tests with a clean DB green. const tiers = mode === 'call' ? DEFAULT_CALL_TIERS : DEFAULT_CHAT_TIERS 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 [row] = await sql` SELECT id, minutes, price_idr, tag FROM pricing_tiers WHERE mode = ${mode} AND minutes = ${durationMinutes} AND is_active = true LIMIT 1 ` if (row) return { id: row.id, minutes: row.minutes, price_idr: row.price_idr, tag: row.tag } // Fallback: empty table → in-memory defaults. const tiers = mode === 'call' ? DEFAULT_CALL_TIERS : DEFAULT_CHAT_TIERS 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 readChatTiers() return { tiers, 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, })) }