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:
51
backend/src/services/auth-providers.service.js
Normal file
51
backend/src/services/auth-providers.service.js
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Phase 4 — server-driven auth-provider gating.
|
||||
*
|
||||
* Probes env at module load. The result is captured at boot, NOT on every request:
|
||||
* - matches the ops contract (operators set the env, restart the backend, the flag
|
||||
* flips). In dev this means a backend restart is required after editing .env.
|
||||
* - keeps the endpoint dirt cheap (no DB / env reads on the hot path).
|
||||
*
|
||||
* Replaces the old `--dart-define=ENABLE_SOCIAL_AUTH` build flag in client_app:
|
||||
* the client now reads `GET /api/shared/auth-providers` once on cold start and
|
||||
* hides Google/Apple buttons when the corresponding flag is `false`.
|
||||
*/
|
||||
|
||||
const isPresent = (key) => {
|
||||
const v = process.env[key]
|
||||
return typeof v === 'string' && v.trim().length > 0
|
||||
}
|
||||
|
||||
const allPresent = (...keys) => keys.every(isPresent)
|
||||
|
||||
// Snapshot taken at module load. If callers need a live value (rare — only env
|
||||
// hot-reload tooling does), they can call `probeAuthProviders()` directly.
|
||||
export const probeAuthProviders = () => ({
|
||||
google: {
|
||||
enabled: allPresent('GOOGLE_OAUTH_CLIENT_ID', 'GOOGLE_OAUTH_CLIENT_SECRET'),
|
||||
},
|
||||
apple: {
|
||||
enabled: allPresent(
|
||||
'APPLE_OAUTH_CLIENT_ID',
|
||||
'APPLE_OAUTH_TEAM_ID',
|
||||
'APPLE_OAUTH_KEY_ID',
|
||||
'APPLE_OAUTH_PRIVATE_KEY',
|
||||
),
|
||||
},
|
||||
// Phone OTP is always available — we don't gate it on env. (The OTP stub or the
|
||||
// Fazpass integration is the only thing that varies, but neither prevents the
|
||||
// phone-OTP entry point from being available.)
|
||||
phone: { enabled: true },
|
||||
})
|
||||
|
||||
let cached = null
|
||||
|
||||
export const getAuthProviders = () => {
|
||||
if (!cached) cached = probeAuthProviders()
|
||||
return cached
|
||||
}
|
||||
|
||||
// Test-only: drop the cache so tests that mutate env between cases see the change.
|
||||
export const _resetAuthProvidersCache = () => {
|
||||
cached = null
|
||||
}
|
||||
@@ -35,33 +35,113 @@ export const setMaxCustomersPerMitra = async (value) => {
|
||||
return { max_customers_per_mitra: value }
|
||||
}
|
||||
|
||||
// --- Phase 3 config ---
|
||||
// --- Phase 4: First-session discount (replaces Phase 3 free-trial config) ---
|
||||
|
||||
export const getFreeTrialConfig = 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'`
|
||||
export const getFirstSessionDiscountConfig = 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_gimmick_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: enabledRow?.value?.value ?? false,
|
||||
duration_minutes: durationRow?.value?.value ?? 5,
|
||||
enabled: byKey.first_session_discount_enabled ?? true,
|
||||
actual_price_idr: byKey.first_session_discount_actual_price_idr ?? 2000,
|
||||
gimmick_price_idr: byKey.first_session_discount_gimmick_price_idr ?? 12000,
|
||||
duration_minutes: byKey.first_session_discount_duration_minutes ?? 12,
|
||||
modes: byKey.first_session_discount_modes ?? ['chat'],
|
||||
}
|
||||
}
|
||||
|
||||
export const setFirstSessionDiscountConfig = async (patch) => {
|
||||
const map = {
|
||||
enabled: 'first_session_discount_enabled',
|
||||
actual_price_idr: 'first_session_discount_actual_price_idr',
|
||||
gimmick_price_idr: 'first_session_discount_gimmick_price_idr',
|
||||
duration_minutes: 'first_session_discount_duration_minutes',
|
||||
modes: 'first_session_discount_modes',
|
||||
}
|
||||
for (const [field, key] of Object.entries(map)) {
|
||||
if (patch[field] === undefined) continue
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value, updated_at)
|
||||
VALUES (${key}, ${sql.json({ value: patch[field] })}, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`
|
||||
}
|
||||
return getFirstSessionDiscountConfig()
|
||||
}
|
||||
|
||||
// Back-compat shim — CC settings page still calls /internal/config/free-trial.
|
||||
// Phase 4 routes will be added; until the CC UI is migrated this maps to the new keys.
|
||||
export const getFreeTrialConfig = async () => {
|
||||
const cfg = await getFirstSessionDiscountConfig()
|
||||
return {
|
||||
enabled: cfg.enabled,
|
||||
duration_minutes: cfg.duration_minutes,
|
||||
}
|
||||
}
|
||||
|
||||
export const setFreeTrialConfig = async ({ enabled, duration_minutes }) => {
|
||||
if (enabled !== undefined) {
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value, updated_at)
|
||||
VALUES ('free_trial_enabled', ${sql.json({ value: enabled })}, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`
|
||||
return setFirstSessionDiscountConfig({
|
||||
...(enabled !== undefined ? { enabled } : {}),
|
||||
...(duration_minutes !== undefined ? { duration_minutes } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
// --- Phase 4: Support handles ---
|
||||
|
||||
export const getSupportHandles = async () => {
|
||||
const [row] = await sql`SELECT value FROM app_config WHERE key = 'support_handles_json'`
|
||||
// Stored shape: { wa: {...}, telegram: {...} }. Fall back to a safe empty payload
|
||||
// so the client renders an empty Tanya Admin sheet rather than crashing.
|
||||
return row?.value ?? {
|
||||
wa: { label: 'WhatsApp', deeplink: '' },
|
||||
telegram: { label: 'Telegram', deeplink: '' },
|
||||
}
|
||||
if (duration_minutes !== undefined) {
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value, updated_at)
|
||||
VALUES ('free_trial_duration_minutes', ${sql.json({ value: duration_minutes })}, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`
|
||||
}
|
||||
|
||||
export const setSupportHandles = async ({ wa, telegram }) => {
|
||||
const current = await getSupportHandles()
|
||||
const next = {
|
||||
wa: { ...current.wa, ...(wa || {}) },
|
||||
telegram: { ...current.telegram, ...(telegram || {}) },
|
||||
}
|
||||
return getFreeTrialConfig()
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value, updated_at)
|
||||
VALUES ('support_handles_json', ${sql.json(next)}, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`
|
||||
return next
|
||||
}
|
||||
|
||||
// --- Phase 4: Pricing tier groups ---
|
||||
|
||||
export const getPricingTierGroups = async () => {
|
||||
const rows = await sql`
|
||||
SELECT key, value FROM app_config
|
||||
WHERE key IN ('pricing_chat_tiers_json', 'pricing_call_tiers_json')
|
||||
`
|
||||
const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value]))
|
||||
return {
|
||||
chat: byKey.pricing_chat_tiers_json?.tiers ?? [],
|
||||
call: byKey.pricing_call_tiers_json?.tiers ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export const setPricingTierGroup = async (mode, tiers) => {
|
||||
const key = mode === 'call' ? 'pricing_call_tiers_json' : 'pricing_chat_tiers_json'
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value, updated_at)
|
||||
VALUES (${key}, ${sql.json({ tiers })}, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`
|
||||
return getPricingTierGroups()
|
||||
}
|
||||
|
||||
export const getExtensionTimeoutConfig = async () => {
|
||||
|
||||
@@ -42,7 +42,7 @@ const getExtensionTimeoutAction = async () => {
|
||||
* - belong to this customer
|
||||
* - be in `confirmed` status (not yet consumed)
|
||||
* - have `is_extension = true`
|
||||
* - have `is_free_trial = false` (extensions never use free trial)
|
||||
* - have `is_first_session_discount = false` (extensions never use the first-session discount)
|
||||
*
|
||||
* The payment session is NOT consumed at request time. It is consumed at approval moment
|
||||
* (mitra explicit accept OR auto-approve fires).
|
||||
@@ -83,9 +83,9 @@ export const requestExtension = async (sessionId, customerId, { duration_minutes
|
||||
code: 'INVALID_STATE', statusCode: 409,
|
||||
})
|
||||
}
|
||||
if (paySession.is_free_trial) {
|
||||
throw Object.assign(new Error('Free trial is not available for extensions'), {
|
||||
code: 'FREE_TRIAL_NOT_ALLOWED', statusCode: 400,
|
||||
if (paySession.is_first_session_discount) {
|
||||
throw Object.assign(new Error('First-session discount is not available for extensions'), {
|
||||
code: 'FIRST_SESSION_DISCOUNT_NOT_ALLOWED', statusCode: 400,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ const requireConfirmedPaymentSession = async (paymentSessionId, customerId, { al
|
||||
/**
|
||||
* General-blast pairing request. Requires a confirmed payment_session_id.
|
||||
*
|
||||
* The duration_minutes / price / is_free_trial values for the chat_session row are
|
||||
* The duration_minutes / price / is_first_session_discount values for the chat_session row are
|
||||
* sourced from the payment session — the client does not dictate pricing here.
|
||||
*
|
||||
* `allowTargetedPayment` is set true by the fallback-to-blast path: the original payment
|
||||
@@ -183,14 +183,14 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic
|
||||
// Create session sourced from the payment session.
|
||||
const [session] = await sql`
|
||||
INSERT INTO chat_sessions (
|
||||
customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity, payment_session_id
|
||||
customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id
|
||||
)
|
||||
VALUES (
|
||||
${customerId}, ${SessionStatus.PENDING_ACCEPTANCE},
|
||||
${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_free_trial},
|
||||
${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_first_session_discount},
|
||||
${resolvedTopic}, ${paymentSessionId}
|
||||
)
|
||||
RETURNING id, customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity, payment_session_id, created_at
|
||||
RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id, created_at
|
||||
`
|
||||
|
||||
// Fan out to all available mitras in parallel — DB inserts and notifications are
|
||||
@@ -206,7 +206,7 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic
|
||||
request_type: PairingRequestType.GENERAL,
|
||||
created_at: session.created_at,
|
||||
duration_minutes: session.duration_minutes,
|
||||
is_free_trial: session.is_free_trial,
|
||||
is_first_session_discount: session.is_first_session_discount,
|
||||
topic_sensitivity: session.topic_sensitivity,
|
||||
})
|
||||
}))
|
||||
@@ -305,14 +305,14 @@ export const createTargetedPairingRequest = async (customerId, { paymentSessionI
|
||||
// Create session sourced from the payment session, status = pending_acceptance.
|
||||
const [session] = await sql`
|
||||
INSERT INTO chat_sessions (
|
||||
customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity, payment_session_id
|
||||
customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id
|
||||
)
|
||||
VALUES (
|
||||
${customerId}, ${SessionStatus.PENDING_ACCEPTANCE},
|
||||
${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_free_trial},
|
||||
${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_first_session_discount},
|
||||
${resolvedTopic}, ${paymentSessionId}
|
||||
)
|
||||
RETURNING id, customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity, payment_session_id, created_at
|
||||
RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id, created_at
|
||||
`
|
||||
|
||||
// Single notification to the targeted mitra
|
||||
@@ -330,7 +330,7 @@ export const createTargetedPairingRequest = async (customerId, { paymentSessionI
|
||||
request_type: PairingRequestType.RETURNING,
|
||||
created_at: session.created_at,
|
||||
duration_minutes: session.duration_minutes,
|
||||
is_free_trial: session.is_free_trial,
|
||||
is_first_session_discount: session.is_first_session_discount,
|
||||
topic_sensitivity: session.topic_sensitivity,
|
||||
confirmation_timeout_seconds: returning_chat_confirmation_timeout_seconds,
|
||||
})
|
||||
@@ -399,12 +399,12 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
|
||||
ELSE NULL
|
||||
END
|
||||
WHERE id = ${sessionId}
|
||||
RETURNING id, customer_id, mitra_id, status, paired_at, duration_minutes, price, is_free_trial, expires_at, payment_session_id
|
||||
RETURNING id, customer_id, mitra_id, status, paired_at, duration_minutes, price, is_first_session_discount, expires_at, payment_session_id
|
||||
`
|
||||
|
||||
// Record transaction
|
||||
if (activeSession.duration_minutes) {
|
||||
const txType = activeSession.is_free_trial ? TransactionType.FREE_TRIAL : TransactionType.PAID
|
||||
const txType = activeSession.is_first_session_discount ? TransactionType.FIRST_SESSION_DISCOUNT : TransactionType.PAID
|
||||
await sql`
|
||||
INSERT INTO customer_transactions (customer_id, session_id, type, amount)
|
||||
VALUES (${activeSession.customer_id}, ${sessionId}, ${txType}, ${activeSession.price || 0})
|
||||
@@ -787,7 +787,7 @@ export const getPendingRequestsForMitra = async (mitraId) => {
|
||||
SELECT
|
||||
cs.id AS session_id,
|
||||
cs.duration_minutes,
|
||||
cs.is_free_trial,
|
||||
cs.is_first_session_discount,
|
||||
cs.topic_sensitivity,
|
||||
cs.created_at,
|
||||
CASE
|
||||
|
||||
@@ -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}
|
||||
`
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -6,18 +6,34 @@ import { UserType, SessionStatus, WsMessage, EndedBy } from '../constants.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
// Active session timers: sessionId → { warningTimeout, expiryTimeout }
|
||||
// Active session timers: sessionId → { threeMinTimeout, warningTimeout, expiryTimeout, threeMinFired }
|
||||
// `threeMinFired` is a per-session idempotency flag — once the 3-min warning has been
|
||||
// emitted for a session it never fires again, even if startSessionTimer is called twice
|
||||
// (e.g. extension reschedule). This matches the Phase 4 spec: "fire once".
|
||||
const sessionTimers = new Map()
|
||||
|
||||
export const startSessionTimer = (sessionId, expiresAt) => {
|
||||
const now = Date.now()
|
||||
const expiresMs = new Date(expiresAt).getTime()
|
||||
const warningMs = expiresMs - 60_000 // 1 minute before expiry
|
||||
const threeMinMs = expiresMs - 180_000 // 3 minutes before expiry (Phase 4)
|
||||
const warningMs = expiresMs - 60_000 // 1 minute before expiry
|
||||
|
||||
// Clear any existing timers
|
||||
// Preserve idempotency flag across reschedules (e.g. extension extends expires_at).
|
||||
const previous = sessionTimers.get(sessionId)
|
||||
const threeMinFired = previous?.threeMinFired ?? false
|
||||
|
||||
// Clear any existing timers (but keep the threeMinFired flag captured above).
|
||||
clearSessionTimer(sessionId)
|
||||
|
||||
const timers = {}
|
||||
const timers = { threeMinFired }
|
||||
|
||||
// 3-min warning timer — Phase 4. Skip if already fired this session, or if the
|
||||
// remaining window is already ≤ 3 min (don't fire belatedly mid-session).
|
||||
if (!threeMinFired && threeMinMs > now) {
|
||||
timers.threeMinTimeout = setTimeout(() => {
|
||||
onThreeMinuteWarning(sessionId)
|
||||
}, threeMinMs - now)
|
||||
}
|
||||
|
||||
// Warning timer (1 min before expiry)
|
||||
if (warningMs > now) {
|
||||
@@ -43,6 +59,7 @@ export const startSessionTimer = (sessionId, expiresAt) => {
|
||||
export const clearSessionTimer = (sessionId) => {
|
||||
const timers = sessionTimers.get(sessionId)
|
||||
if (timers) {
|
||||
if (timers.threeMinTimeout) clearTimeout(timers.threeMinTimeout)
|
||||
if (timers.warningTimeout) clearTimeout(timers.warningTimeout)
|
||||
if (timers.expiryTimeout) clearTimeout(timers.expiryTimeout)
|
||||
sessionTimers.delete(sessionId)
|
||||
@@ -69,6 +86,21 @@ const onSessionWarning = (sessionId) => {
|
||||
sendToSessionParticipant(sessionId, UserType.MITRA, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 4 — 3-min warning. Customer-only event (mitra has no countdown UI).
|
||||
* Idempotent per session via the `threeMinFired` flag captured by startSessionTimer.
|
||||
*/
|
||||
const onThreeMinuteWarning = (sessionId) => {
|
||||
const timers = sessionTimers.get(sessionId)
|
||||
if (timers?.threeMinFired) return // belt-and-braces — should not happen
|
||||
if (timers) timers.threeMinFired = true
|
||||
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
|
||||
type: WsMessage.SESSION_WARNING,
|
||||
kind: 'three_minutes_left',
|
||||
session_id: sessionId,
|
||||
})
|
||||
}
|
||||
|
||||
// Grace period timers for auto-completing abandoned sessions
|
||||
const closureGraceTimers = new Map()
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ const sql = getDb()
|
||||
export const getActiveSessionByCustomer = async (customerId) => {
|
||||
const [session] = await sql`
|
||||
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at,
|
||||
cs.duration_minutes, cs.price, cs.is_free_trial, cs.expires_at, cs.extended_minutes,
|
||||
cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.expires_at, cs.extended_minutes,
|
||||
m.display_name AS mitra_display_name
|
||||
FROM chat_sessions cs
|
||||
LEFT JOIN mitras m ON m.id = cs.mitra_id
|
||||
@@ -152,7 +152,7 @@ export const getSessionById = async (sessionId) => {
|
||||
const [session] = await sql`
|
||||
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity,
|
||||
cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
|
||||
cs.duration_minutes, cs.price, cs.is_free_trial, cs.expires_at, cs.extended_minutes,
|
||||
cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.expires_at, cs.extended_minutes,
|
||||
c.display_name AS customer_display_name,
|
||||
m.display_name AS mitra_display_name
|
||||
FROM chat_sessions cs
|
||||
@@ -168,7 +168,7 @@ export const getSessionById = async (sessionId) => {
|
||||
export const getActiveSessionByCustomerWithUnread = async (customerId) => {
|
||||
const [session] = await sql`
|
||||
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at,
|
||||
cs.duration_minutes, cs.price, cs.is_free_trial, cs.expires_at, cs.extended_minutes,
|
||||
cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.expires_at, cs.extended_minutes,
|
||||
m.display_name AS mitra_display_name,
|
||||
(SELECT COUNT(*) FROM chat_messages cm
|
||||
WHERE cm.session_id = cs.id AND cm.sender_type = ${UserType.MITRA}
|
||||
@@ -203,7 +203,7 @@ export const getCustomerHistory = async (customerId, { page = 1, limit = 20 } =
|
||||
const offset = (page - 1) * limit
|
||||
const items = await sql`
|
||||
SELECT cs.id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at, cs.ended_at,
|
||||
cs.duration_minutes, cs.price, cs.is_free_trial, cs.extended_minutes,
|
||||
cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.extended_minutes,
|
||||
m.display_name AS mitra_display_name,
|
||||
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.MITRA} LIMIT 1) AS mitra_closure_message,
|
||||
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.CUSTOMER} LIMIT 1) AS customer_closure_message
|
||||
@@ -225,7 +225,7 @@ export const getMitraHistory = async (mitraId, { page = 1, limit = 20 } = {}) =>
|
||||
const offset = (page - 1) * limit
|
||||
const items = await sql`
|
||||
SELECT cs.id, cs.customer_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at, cs.ended_at,
|
||||
cs.duration_minutes, cs.price, cs.is_free_trial, cs.extended_minutes,
|
||||
cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.extended_minutes,
|
||||
c.display_name AS customer_display_name,
|
||||
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.MITRA} LIMIT 1) AS mitra_closure_message,
|
||||
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.CUSTOMER} LIMIT 1) AS customer_closure_message
|
||||
|
||||
Reference in New Issue
Block a user