From d33d4419ead74548b505d9d66cda52be90af7bd6 Mon Sep 17 00:00:00 2001 From: ramadhan sjamsani Date: Sun, 10 May 2026 15:56:28 +0800 Subject: [PATCH] Phase 4 Stage 1: backend foundation (additive endpoints + schema) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/src/app.public.js | 8 + backend/src/constants.js | 14 +- backend/src/db/migrate.js | 105 ++++++++ backend/src/routes/internal/config.routes.js | 103 +++++++ .../src/routes/public/client.chat.routes.js | 7 +- .../routes/public/client.onboarding.routes.js | 60 +++++ .../routes/public/client.payment.routes.js | 80 ++++-- .../routes/public/client.support.routes.js | 14 + .../public/shared.auth-providers.routes.js | 14 + .../src/services/auth-providers.service.js | 51 ++++ backend/src/services/config.service.js | 118 ++++++-- backend/src/services/extension.service.js | 8 +- backend/src/services/pairing.service.js | 24 +- backend/src/services/payment.service.js | 32 ++- backend/src/services/pricing.service.js | 200 ++++++++++---- backend/src/services/session-timer.service.js | 40 ++- backend/src/services/session.service.js | 10 +- backend/test/helpers/db.js | 12 +- .../routes/client.chat-pricing.routes.test.js | 98 +++++++ .../test/routes/client.payment.routes.test.js | 79 ++++-- .../shared.auth-providers.routes.test.js | 87 ++++++ backend/test/services/payment.service.test.js | 3 +- .../services/session-timer.service.test.js | 91 +++++++ .../src/pages/settings/SettingsPage.jsx | 251 +++++++++++++++++- 24 files changed, 1347 insertions(+), 162 deletions(-) create mode 100644 backend/src/routes/public/client.onboarding.routes.js create mode 100644 backend/src/routes/public/client.support.routes.js create mode 100644 backend/src/routes/public/shared.auth-providers.routes.js create mode 100644 backend/src/services/auth-providers.service.js create mode 100644 backend/test/routes/client.chat-pricing.routes.test.js create mode 100644 backend/test/routes/shared.auth-providers.routes.test.js create mode 100644 backend/test/services/session-timer.service.test.js diff --git a/backend/src/app.public.js b/backend/src/app.public.js index 0d40551..8a0a89f 100644 --- a/backend/src/app.public.js +++ b/backend/src/app.public.js @@ -5,11 +5,14 @@ import { sharedAuthRoutes } from './routes/public/shared.auth.routes.js' import { clientAuthRoutes } from './routes/public/client.auth.routes.js' import { mitraAuthRoutes } from './routes/public/mitra.auth.routes.js' import { sharedConfigRoutes } from './routes/public/shared.config.routes.js' +import { sharedAuthProvidersRoutes } from './routes/public/shared.auth-providers.routes.js' import { mitraStatusRoutes } from './routes/public/mitra.status.routes.js' import { mitraChatRoutes } from './routes/public/mitra.chat.routes.js' import { clientChatRoutes } from './routes/public/client.chat.routes.js' import { clientPaymentRoutes } from './routes/public/client.payment.routes.js' import { clientMitraAvailabilityRoutes } from './routes/public/client.mitra-availability.routes.js' +import { clientOnboardingRoutes } from './routes/public/client.onboarding.routes.js' +import { clientSupportRoutes } from './routes/public/client.support.routes.js' import { sharedChatRoutes } from './routes/public/shared.chat.routes.js' import { errorHandler } from './plugins/error-handler.js' import { registerWebSocketPlugin, registerWebSocketRoute } from './plugins/websocket.js' @@ -24,6 +27,7 @@ export const buildPublicApp = async () => { app.register(sharedAuthRoutes, { prefix: '/api/shared/auth' }) app.register(sharedConfigRoutes, { prefix: '/api/shared/config' }) + app.register(sharedAuthProvidersRoutes, { prefix: '/api/shared/auth-providers' }) app.register(sharedChatRoutes, { prefix: '/api/shared' }) app.register(clientAuthRoutes, { prefix: '/api/client/auth' }) app.register(mitraAuthRoutes, { prefix: '/api/mitra/auth' }) @@ -32,6 +36,10 @@ export const buildPublicApp = async () => { app.register(clientChatRoutes, { prefix: '/api/client/chat' }) app.register(clientPaymentRoutes, { prefix: '/api/client/payment-sessions' }) app.register(clientMitraAvailabilityRoutes, { prefix: '/api/client/mitra-availability' }) + // Phase 4: onboarding-state + support handles. Both are tiny so they live in their + // own files rather than bloating client.auth.routes / shared.config.routes. + app.register(clientOnboardingRoutes, { prefix: '/api/client' }) + app.register(clientSupportRoutes, { prefix: '/api/client' }) // WebSocket route (registered at app level, not prefixed) registerWebSocketRoute(app) diff --git a/backend/src/constants.js b/backend/src/constants.js index 32afabc..7c01bc1 100644 --- a/backend/src/constants.js +++ b/backend/src/constants.js @@ -48,11 +48,20 @@ export const ExtensionStatus = Object.freeze({ // Customer transaction types export const TransactionType = Object.freeze({ - FREE_TRIAL: 'free_trial', + // Phase 4: replaces FREE_TRIAL. Eligibility = phone-verified + no completed sessions + // + first_session_discount_enabled. Discounted price comes from app_config, not 0. + FIRST_SESSION_DISCOUNT: 'first_session_discount', PAID: 'paid', EXTENSION: 'extension', }) +// Mode of a chat/payment session — chat (default) or voice call. Voice call is just +// chat with a different price group + a header badge; no extra media handling. +export const SessionMode = Object.freeze({ + CHAT: 'chat', + CALL: 'call', +}) + // Payment session lifecycle export const PaymentSessionStatus = Object.freeze({ PENDING: 'pending', @@ -144,6 +153,9 @@ export const WsMessage = Object.freeze({ // Session lifecycle SESSION_TIMER: 'session_timer', + // Phase 4: in-session early warning. Currently fires once at 3 minutes left ("kind: + // three_minutes_left"). Customer-only — mitra has no countdown UI. + SESSION_WARNING: 'session_warning', SESSION_EXPIRED: 'session_expired', SESSION_CLOSING: 'session_closing', SESSION_COMPLETED: 'session_completed', diff --git a/backend/src/db/migrate.js b/backend/src/db/migrate.js index 76b4464..3004d22 100644 --- a/backend/src/db/migrate.js +++ b/backend/src/db/migrate.js @@ -549,6 +549,111 @@ const migrate = async () => { ON CONFLICT (key) DO NOTHING ` + // --- Phase 4 — Customer Flow Redesign --- + + // 1. payment_sessions + chat_sessions: replace is_free_trial with is_first_session_discount. + // Phase 3.7 was the first ship of is_free_trial and never went live with real users + // (per project memory), so we copy whatever values exist and drop the old column. + // Idempotent: ADD/DROP both use IF [NOT] EXISTS, and each UPDATE is gated on the + // old column still existing. + await sql` + ALTER TABLE payment_sessions + ADD COLUMN IF NOT EXISTS is_first_session_discount BOOLEAN NOT NULL DEFAULT false + ` + await sql` + ALTER TABLE chat_sessions + ADD COLUMN IF NOT EXISTS is_first_session_discount BOOLEAN NOT NULL DEFAULT false + ` + + // Copy values from the legacy column to the new one. We must use dynamic SQL + // (EXECUTE) inside the DO block — a static reference to is_free_trial would fail + // to parse when the column has already been dropped on a previous re-run. + // + // The IF EXISTS check resolves the column against the *current* search_path so + // test schemas don't false-positive on the dev `public` schema's leftover columns. + // We use to_regclass + pg_attribute (which is search_path-aware) instead of + // information_schema.columns (which lists every schema). + await sql` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM pg_attribute + WHERE attrelid = to_regclass('payment_sessions') + AND attname = 'is_free_trial' + AND NOT attisdropped + ) THEN + EXECUTE 'UPDATE payment_sessions + SET is_first_session_discount = is_free_trial + WHERE is_free_trial = true + AND is_first_session_discount = false'; + END IF; + IF EXISTS ( + SELECT 1 FROM pg_attribute + WHERE attrelid = to_regclass('chat_sessions') + AND attname = 'is_free_trial' + AND NOT attisdropped + ) THEN + EXECUTE 'UPDATE chat_sessions + SET is_first_session_discount = is_free_trial + WHERE is_free_trial = true + AND is_first_session_discount = false'; + END IF; + END + $$ + ` + + await sql`ALTER TABLE payment_sessions DROP COLUMN IF EXISTS is_free_trial` + await sql`ALTER TABLE chat_sessions DROP COLUMN IF EXISTS is_free_trial` + + // 2. payment_sessions.mode — chat (default) vs voice call. Voice call is just chat + // with a different price group + a header badge; no extra media handling. + await sql` + ALTER TABLE payment_sessions + ADD COLUMN IF NOT EXISTS mode TEXT NOT NULL DEFAULT 'chat' + CHECK (mode IN ('chat', 'call')) + ` + + // 3. chat_sessions.topics — ESP picks persisted for info-only display to mitra. + // Does NOT affect matching, pricing, or routing. + await sql` + ALTER TABLE chat_sessions + ADD COLUMN IF NOT EXISTS topics TEXT[] + ` + + // 4. Phase 4 app_config rows. Use ON CONFLICT (key) DO NOTHING so re-runs don't + // clobber operator edits, and the migration is idempotent against partially + // populated DBs. + await sql` + INSERT INTO app_config (key, value) VALUES + ('payment_method_qris_first', ${sql.json({ value: true })}), + ('searching_timeout_minutes', ${sql.json({ value: 5 })}), + ('end_session_two_step_confirm', ${sql.json({ value: true })}), + ('three_minute_warning_enabled', ${sql.json({ value: true })}), + ('first_session_discount_enabled', ${sql.json({ value: true })}), + ('first_session_discount_actual_price_idr', ${sql.json({ value: 2000 })}), + ('first_session_discount_gimmick_price_idr', ${sql.json({ value: 12000 })}), + ('first_session_discount_duration_minutes', ${sql.json({ value: 12 })}), + ('first_session_discount_modes', ${sql.json({ value: ['chat'] })}), + ('pricing_chat_tiers_json', ${sql.json({ 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' }, + ]})}), + ('pricing_call_tiers_json', ${sql.json({ 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' }, + ]})}), + ('support_handles_json', ${sql.json({ + wa: { label: 'WhatsApp', deeplink: 'https://wa.me/6285173310010' }, + telegram: { label: 'Telegram', deeplink: 'https://t.me/halobestie' }, + })}) + ON CONFLICT (key) DO NOTHING + ` + console.log('Migration complete.') await sql.end() } diff --git a/backend/src/routes/internal/config.routes.js b/backend/src/routes/internal/config.routes.js index 782b8a2..bc049a2 100644 --- a/backend/src/routes/internal/config.routes.js +++ b/backend/src/routes/internal/config.routes.js @@ -14,6 +14,9 @@ import { getReturningChatConfirmationTimeoutSeconds, setReturningChatConfirmationTimeoutSeconds, getExtensionDefaultActionOnTimeout, setExtensionDefaultActionOnTimeout, getPairingBlastTimeoutSeconds, setPairingBlastTimeoutSeconds, + getFirstSessionDiscountConfig, setFirstSessionDiscountConfig, + getSupportHandles, setSupportHandles, + getPricingTierGroups, setPricingTierGroup, } from '../../services/config.service.js' const attachCcUser = async (request, reply) => { @@ -284,4 +287,104 @@ export const internalConfigRoutes = async (app) => { await publishConfigInvalidate('pairing_blast_timeout_seconds') return reply.send({ success: true, data: config }) }) + + // --- Phase 4: First-session discount --- + app.get('/first-session-discount', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')], + }, async (_req, reply) => { + return reply.send({ success: true, data: await getFirstSessionDiscountConfig() }) + }) + + app.patch('/first-session-discount', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')], + }, async (request, reply) => { + const { enabled, actual_price_idr, gimmick_price_idr, duration_minutes, modes } = request.body ?? {} + const patch = {} + if (enabled !== undefined) { + if (typeof enabled !== 'boolean') { + return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'enabled must be a boolean' } }) + } + patch.enabled = enabled + } + for (const [field, value] of [ + ['actual_price_idr', actual_price_idr], + ['gimmick_price_idr', gimmick_price_idr], + ['duration_minutes', duration_minutes], + ]) { + if (value !== undefined) { + if (typeof value !== 'number' || value < 0 || !Number.isFinite(value)) { + return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: `${field} must be a non-negative number` } }) + } + patch[field] = Math.round(value) + } + } + if (modes !== undefined) { + if (!Array.isArray(modes) || modes.some((m) => m !== 'chat' && m !== 'call')) { + return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'modes must be an array of "chat" | "call"' } }) + } + patch.modes = modes + } + const config = await setFirstSessionDiscountConfig(patch) + await publishConfigInvalidate('first_session_discount') + return reply.send({ success: true, data: config }) + }) + + // --- Phase 4: Pricing tier groups (chat / call) --- + app.get('/pricing-tiers', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')], + }, async (_req, reply) => { + return reply.send({ success: true, data: await getPricingTierGroups() }) + }) + + app.patch('/pricing-tiers/:mode', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')], + }, async (request, reply) => { + const mode = request.params.mode + if (mode !== 'chat' && mode !== 'call') { + return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'mode must be chat or call' } }) + } + const { tiers } = request.body ?? {} + if (!Array.isArray(tiers) || tiers.length === 0) { + return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'tiers must be a non-empty array' } }) + } + for (const t of tiers) { + if ( + typeof t.id !== 'string' + || typeof t.minutes !== 'number' || t.minutes <= 0 + || typeof t.price_idr !== 'number' || t.price_idr < 0 + ) { + return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'each tier needs id (string), minutes (number > 0), price_idr (number >= 0)' } }) + } + } + const config = await setPricingTierGroup(mode, tiers) + await publishConfigInvalidate(`pricing_${mode}_tiers_json`) + return reply.send({ success: true, data: config }) + }) + + // --- Phase 4: Support handles --- + app.get('/support-handles', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')], + }, async (_req, reply) => { + return reply.send({ success: true, data: await getSupportHandles() }) + }) + + app.patch('/support-handles', { + preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')], + }, async (request, reply) => { + const { wa, telegram } = request.body ?? {} + const validateHandle = (h, name) => { + if (h === undefined) return null + if (typeof h !== 'object' || h === null) return `${name} must be an object` + if (h.label !== undefined && typeof h.label !== 'string') return `${name}.label must be a string` + if (h.deeplink !== undefined && typeof h.deeplink !== 'string') return `${name}.deeplink must be a string` + return null + } + for (const [name, value] of [['wa', wa], ['telegram', telegram]]) { + const err = validateHandle(value, name) + if (err) return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: err } }) + } + const config = await setSupportHandles({ wa, telegram }) + await publishConfigInvalidate('support_handles_json') + return reply.send({ success: true, data: config }) + }) } diff --git a/backend/src/routes/public/client.chat.routes.js b/backend/src/routes/public/client.chat.routes.js index 5d16f13..e213e6d 100644 --- a/backend/src/routes/public/client.chat.routes.js +++ b/backend/src/routes/public/client.chat.routes.js @@ -35,7 +35,10 @@ const resolveCustomer = async (request, reply) => { } export const clientChatRoutes = async (app) => { - // Get pricing tiers + free trial eligibility + // Get chat + call pricing tiers + first-session-discount eligibility (per-customer). + // Phase 4 reshape — tiers come from `app_config.pricing_{chat,call}_tiers_json` and + // discount eligibility is the AND of: phone-verified + no completed sessions + + // first_session_discount_enabled. app.get('/pricing', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { const pricing = await getPricingForCustomer(request.customer.id) return reply.send({ success: true, data: pricing }) @@ -171,7 +174,7 @@ export const clientChatRoutes = async (app) => { /** * Extension request REQUIRES `extension_payment_session_id`. - * The payment session must be is_extension=true and is_free_trial=false. + * The payment session must be is_extension=true and is_first_session_discount=false. * Pricing/duration come from the payment session via the extension service. */ app.post('/session/:sessionId/extend', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { diff --git a/backend/src/routes/public/client.onboarding.routes.js b/backend/src/routes/public/client.onboarding.routes.js new file mode 100644 index 0000000..88bb9f1 --- /dev/null +++ b/backend/src/routes/public/client.onboarding.routes.js @@ -0,0 +1,60 @@ +import { authenticate } from '../../plugins/auth.js' +import { getCustomerById } from '../../services/customer.service.js' +import { isCustomerEligibleForFirstSessionDiscount } from '../../services/pricing.service.js' +import { getDb } from '../../db/client.js' +import { UserType, SessionStatus } from '../../constants.js' + +const sql = getDb() + +/** + * Phase 4 onboarding-state endpoint. Drives: + * - Verif Choice Sheet visibility on the post-name screen. + * - S6 paywall vs Pilih cara routing decision. + * + * Eligibility predicate (server-authoritative — client never decides): + * first_session_discount_enabled AND phone-verified AND no completed sessions. + * + * NOTE: deviates from the plan's `users.phone_verified_at` reference — there is no + * such column. `customers.phone IS NOT NULL` is equivalent in this schema (phone is + * only ever set by the OTP-verify path). + */ +export const clientOnboardingRoutes = async (app) => { + app.get('/onboarding-state', { preHandler: authenticate }, async (request, reply) => { + if (request.auth?.userType !== UserType.CUSTOMER) { + return reply.code(403).send({ + success: false, + error: { code: 'FORBIDDEN', message: 'Customer account required' }, + }) + } + const customer = await getCustomerById(request.auth.userId) + if (!customer) { + return reply.code(404).send({ + success: false, + error: { code: 'ACCOUNT_NOT_FOUND', message: 'Customer account not found' }, + }) + } + + const isPhoneVerified = !!customer.phone + + const [prior] = await sql` + SELECT id FROM chat_sessions + WHERE customer_id = ${customer.id} + AND status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING}) + LIMIT 1 + ` + const hasConsultedBefore = !!prior + + // Use the same predicate the pricing endpoint uses, so the two stay in lock-step. + const isFirstSessionDiscountEligible = await isCustomerEligibleForFirstSessionDiscount(customer.id) + + return reply.send({ + success: true, + data: { + has_consulted_before: hasConsultedBefore, + is_phone_verified: isPhoneVerified, + is_first_session_discount_eligible: isFirstSessionDiscountEligible, + is_anonymous: !!customer.is_anonymous, + }, + }) + }) +} diff --git a/backend/src/routes/public/client.payment.routes.js b/backend/src/routes/public/client.payment.routes.js index 5bdac0c..0e317f7 100644 --- a/backend/src/routes/public/client.payment.routes.js +++ b/backend/src/routes/public/client.payment.routes.js @@ -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, diff --git a/backend/src/routes/public/client.support.routes.js b/backend/src/routes/public/client.support.routes.js new file mode 100644 index 0000000..487b199 --- /dev/null +++ b/backend/src/routes/public/client.support.routes.js @@ -0,0 +1,14 @@ +import { authenticate } from '../../plugins/auth.js' +import { getSupportHandles } from '../../services/config.service.js' + +/** + * Phase 4 — Tanya Admin sheet handles. Sourced from `app_config.support_handles_json`, + * editable by CC. Authenticated so unauthenticated callers can't enumerate the + * support channels (rate-limit hardening, not a secret). + */ +export const clientSupportRoutes = async (app) => { + app.get('/support-handles', { preHandler: authenticate }, async (_request, reply) => { + const handles = await getSupportHandles() + return reply.send({ success: true, data: handles }) + }) +} diff --git a/backend/src/routes/public/shared.auth-providers.routes.js b/backend/src/routes/public/shared.auth-providers.routes.js new file mode 100644 index 0000000..5567a0d --- /dev/null +++ b/backend/src/routes/public/shared.auth-providers.routes.js @@ -0,0 +1,14 @@ +import { getAuthProviders } from '../../services/auth-providers.service.js' + +/** + * GET /api/shared/auth-providers — public, no auth required. + * + * Tells the client which auth entry points are wired up server-side. The client uses + * this to hide Google/Apple buttons when the corresponding OAuth env vars aren't + * configured (avoids a "press button → mysterious 500" UX). + */ +export const sharedAuthProvidersRoutes = async (app) => { + app.get('/', async (_request, reply) => { + return reply.send({ success: true, data: getAuthProviders() }) + }) +} diff --git a/backend/src/services/auth-providers.service.js b/backend/src/services/auth-providers.service.js new file mode 100644 index 0000000..d9a15e7 --- /dev/null +++ b/backend/src/services/auth-providers.service.js @@ -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 +} diff --git a/backend/src/services/config.service.js b/backend/src/services/config.service.js index 2531de3..f4d158e 100644 --- a/backend/src/services/config.service.js +++ b/backend/src/services/config.service.js @@ -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 () => { diff --git a/backend/src/services/extension.service.js b/backend/src/services/extension.service.js index beff0bf..467ccbe 100644 --- a/backend/src/services/extension.service.js +++ b/backend/src/services/extension.service.js @@ -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, }) } diff --git a/backend/src/services/pairing.service.js b/backend/src/services/pairing.service.js index c282842..6be07b3 100644 --- a/backend/src/services/pairing.service.js +++ b/backend/src/services/pairing.service.js @@ -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 diff --git a/backend/src/services/payment.service.js b/backend/src/services/payment.service.js index da3f037..91b94e6 100644 --- a/backend/src/services/payment.service.js +++ b/backend/src/services/payment.service.js @@ -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} ` diff --git a/backend/src/services/pricing.service.js b/backend/src/services/pricing.service.js index b8aa4d2..a1d2a8e 100644 --- a/backend/src/services/pricing.service.js +++ b/backend/src/services/pricing.service.js @@ -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, + })) +} diff --git a/backend/src/services/session-timer.service.js b/backend/src/services/session-timer.service.js index 3f87d80..68be78d 100644 --- a/backend/src/services/session-timer.service.js +++ b/backend/src/services/session-timer.service.js @@ -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() diff --git a/backend/src/services/session.service.js b/backend/src/services/session.service.js index 0e062eb..b597884 100644 --- a/backend/src/services/session.service.js +++ b/backend/src/services/session.service.js @@ -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 diff --git a/backend/test/helpers/db.js b/backend/test/helpers/db.js index 896ec9c..faa0137 100644 --- a/backend/test/helpers/db.js +++ b/backend/test/helpers/db.js @@ -52,7 +52,8 @@ export const resetDbHard = async () => { /** * Drop and re-seed the configurable app_config rows back to their canonical defaults. - * Tests that mutate config (e.g. flipping free_trial_enabled) call this in afterEach. + * Tests that mutate config (e.g. flipping first_session_discount_enabled) call this + * in afterEach. */ export const resetAppConfig = async () => { const sql = db() @@ -61,8 +62,6 @@ export const resetAppConfig = async () => { const defaults = [ ['anonymity', { enabled: false }], ['max_customers_per_mitra', { value: 3 }], - ['free_trial_enabled', { value: true }], - ['free_trial_duration_minutes', { value: 5 }], ['extension_timeout_seconds', { value: 60 }], ['early_end_mitra_enabled', { value: false }], ['early_end_customer_enabled', { value: false }], @@ -70,6 +69,13 @@ export const resetAppConfig = async () => { ['returning_chat_confirmation_timeout_seconds', { value: 20 }], ['extension_default_action_on_timeout', { value: 'auto_approve' }], ['pairing_blast_timeout_seconds', { value: 60 }], + // Phase 4 + ['first_session_discount_enabled', { value: true }], + ['first_session_discount_actual_price_idr', { value: 2000 }], + ['first_session_discount_gimmick_price_idr', { value: 12000 }], + ['first_session_discount_duration_minutes', { value: 12 }], + ['first_session_discount_modes', { value: ['chat'] }], + ['three_minute_warning_enabled', { value: true }], ] for (const [key, value] of defaults) { await sql` diff --git a/backend/test/routes/client.chat-pricing.routes.test.js b/backend/test/routes/client.chat-pricing.routes.test.js new file mode 100644 index 0000000..f1e5ccf --- /dev/null +++ b/backend/test/routes/client.chat-pricing.routes.test.js @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest' + +vi.mock('../../src/plugins/websocket.js', () => ({ + sendToUser: vi.fn(() => false), + sendToSessionParticipant: vi.fn(() => false), + registerWebSocketPlugin: vi.fn(async () => {}), + registerWebSocketRoute: vi.fn(), + isUserOnlineWs: vi.fn(() => false), + getSessionConnections: vi.fn(() => ({})), +})) + +vi.mock('../../src/services/notification.service.js', () => ({ + sendPushNotification: vi.fn(async () => true), + registerDeviceToken: vi.fn(async () => {}), +})) + +const { buildPublic } = await import('../helpers/server.js') +const { resetDb, resetAppConfig, db } = await import('../helpers/db.js') +const { createCustomer } = await import('../helpers/fixtures.js') +const { customerJwt, authHeader } = await import('../helpers/jwt.js') + +describe('GET /api/client/chat/pricing (Phase 4)', () => { + let app + let customer + let token + + beforeAll(async () => { + await resetAppConfig() + app = await buildPublic() + }) + + beforeEach(async () => { + await resetDb() + const phone = `+628${Math.floor(Math.random() * 1e10).toString().padStart(10, '0')}` + customer = await createCustomer({ callName: 'PricingTester', phone }) + token = customerJwt(customer.id) + }) + + afterAll(async () => { + await app?.close() + }) + + it('returns chat + call tier groups and a discount block; eligibility flips when the customer has a completed session', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/client/chat/pricing', + headers: authHeader(token), + }) + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.success).toBe(true) + const data = body.data + + // Two tier groups, both non-empty + expect(Array.isArray(data.chat?.tiers)).toBe(true) + expect(Array.isArray(data.call?.tiers)).toBe(true) + expect(data.chat.tiers.length).toBeGreaterThan(0) + expect(data.call.tiers.length).toBeGreaterThan(0) + + // Tier shape (chat 12-min should match the seed config) + const chat12 = data.chat.tiers.find((t) => t.minutes === 12) + expect(chat12).toBeDefined() + expect(chat12.price_idr).toBe(12000) + + // Discount block — eligible (phone-verified + no completed sessions) + expect(data.first_session_discount.eligible).toBe(true) + expect(data.first_session_discount.actual_price_idr).toBe(2000) + expect(data.first_session_discount.gimmick_price_idr).toBe(12000) + expect(data.first_session_discount.duration_minutes).toBe(12) + expect(data.first_session_discount.modes).toEqual(['chat']) + + // Insert a completed session — eligibility must flip. + const sql = db() + await sql` + INSERT INTO chat_sessions (customer_id, status, duration_minutes, price) + VALUES (${customer.id}, 'completed', 12, 12000) + ` + const after = await app.inject({ + method: 'GET', + url: '/api/client/chat/pricing', + headers: authHeader(token), + }) + expect(after.statusCode).toBe(200) + expect(after.json().data.first_session_discount.eligible).toBe(false) + }) + + it('eligibility is false when phone is not set (anonymous customer)', async () => { + const anon = await createCustomer({ callName: 'AnonCust', phone: null }) + const anonToken = customerJwt(anon.id) + const res = await app.inject({ + method: 'GET', + url: '/api/client/chat/pricing', + headers: authHeader(anonToken), + }) + expect(res.statusCode).toBe(200) + expect(res.json().data.first_session_discount.eligible).toBe(false) + }) +}) diff --git a/backend/test/routes/client.payment.routes.test.js b/backend/test/routes/client.payment.routes.test.js index 45110d9..ee92f56 100644 --- a/backend/test/routes/client.payment.routes.test.js +++ b/backend/test/routes/client.payment.routes.test.js @@ -34,7 +34,11 @@ describe('POST /api/client/payment-sessions', () => { beforeEach(async () => { await resetDb() - customer = await createCustomer({ callName: 'PaymentTester' }) + // Phone-verified customer (phone non-null) is required for first-session-discount + // eligibility under the Phase 4 predicate. + // Random suffix avoids the unique-phone constraint clashing with parallel test files. + const phone = `+628${Math.floor(Math.random() * 1e10).toString().padStart(10, '0')}` + customer = await createCustomer({ callName: 'PaymentTester', phone }) token = customerJwt(customer.id) }) @@ -42,25 +46,25 @@ describe('POST /api/client/payment-sessions', () => { await app?.close() }) - it('happy path returns 201 + a pending payment-session row', async () => { + it('happy path returns 201 + a pending payment-session row at the discounted price for an eligible customer', async () => { const res = await app.inject({ method: 'POST', url: '/api/client/payment-sessions', headers: authHeader(token), - payload: { duration_minutes: 15 }, + // Discount duration default is 12 minutes (config seed). Eligible customer → + // amount forced to actual_price_idr (2000), is_first_session_discount=true. + payload: { duration_minutes: 12 }, }) expect(res.statusCode).toBe(201) const body = res.json() expect(body.success).toBe(true) expect(body.data.status).toBe(PaymentSessionStatus.PENDING) - expect(body.data.duration_minutes).toBe(15) - // Default tier for 15min from migrate.js is 30000 — but the eligibility logic - // also needs `free_trial_enabled` to be true (default) AND no prior tx. Customer is - // brand-new so they get the trial → amount=0, is_free_trial=true. Verify accordingly. - expect(body.data.is_free_trial).toBe(true) - expect(body.data.amount).toBe(0) + expect(body.data.duration_minutes).toBe(12) + expect(body.data.is_first_session_discount).toBe(true) + expect(body.data.amount).toBe(2000) expect(body.data.is_extension).toBe(false) + expect(body.data.mode).toBe('chat') // Verify persistence const sql = db() @@ -69,35 +73,41 @@ describe('POST /api/client/payment-sessions', () => { expect(row.customer_id).toBe(customer.id) }) - it('POST /:id/confirm transitions the row and returns 200', async () => { - // Create a paid (non-trial) tier so the row has a non-zero amount and we exercise the - // confirm path with a "real" payment. Insert a transaction first so the customer is - // ineligible for the free trial. + it('non-eligible customer pays the standard tier price', async () => { + // Drop first-session-discount eligibility by inserting a completed session. const sql = db() - // Bootstrap: create a fake prior chat session + transaction so the customer is no - // longer eligible for the free trial. (The simpler alternative — flipping - // free_trial_enabled in app_config — would impact other tests.) - const [prior] = await sql` - INSERT INTO chat_sessions (customer_id, status, duration_minutes, price) - VALUES (${customer.id}, 'completed', 15, 30000) - RETURNING id - ` await sql` - INSERT INTO customer_transactions (customer_id, session_id, type, amount) - VALUES (${customer.id}, ${prior.id}, 'paid', 30000) + INSERT INTO chat_sessions (customer_id, status, duration_minutes, price) + VALUES (${customer.id}, 'completed', 12, 12000) ` + const res = await app.inject({ + method: 'POST', + url: '/api/client/payment-sessions', + headers: authHeader(token), + payload: { duration_minutes: 12 }, + }) + + expect(res.statusCode).toBe(201) + const body = res.json() + expect(body.data.is_first_session_discount).toBe(false) + // 12-minute tier in Phase 4 chat tiers = 12000 IDR. + expect(body.data.amount).toBe(12000) + }) + + it('POST /:id/confirm transitions the row and returns 200', async () => { + // Use a non-discount tier (5 min @ 5000 IDR) so we exercise the regular confirm path. const createRes = await app.inject({ method: 'POST', url: '/api/client/payment-sessions', headers: authHeader(token), - payload: { duration_minutes: 15 }, + payload: { duration_minutes: 5 }, }) expect(createRes.statusCode).toBe(201) const created = createRes.json().data expect(created.status).toBe(PaymentSessionStatus.PENDING) - expect(created.is_free_trial).toBe(false) - expect(created.amount).toBe(30000) + expect(created.is_first_session_discount).toBe(false) + expect(created.amount).toBe(5000) const confirmRes = await app.inject({ method: 'POST', @@ -112,4 +122,21 @@ describe('POST /api/client/payment-sessions', () => { expect(confirmed.status).toBe(PaymentSessionStatus.CONFIRMED) expect(confirmed.confirmed_at).toBeTruthy() }) + + it('call-mode payment session uses the call tier price group', async () => { + // 20-minute call tier in Phase 4 = 17000 IDR. + const res = await app.inject({ + method: 'POST', + url: '/api/client/payment-sessions', + headers: authHeader(token), + payload: { duration_minutes: 20, mode: 'call' }, + }) + + expect(res.statusCode).toBe(201) + const body = res.json() + expect(body.data.mode).toBe('call') + // Eligible customer but discount modes default = ['chat'], so call is full price. + expect(body.data.is_first_session_discount).toBe(false) + expect(body.data.amount).toBe(17000) + }) }) diff --git a/backend/test/routes/shared.auth-providers.routes.test.js b/backend/test/routes/shared.auth-providers.routes.test.js new file mode 100644 index 0000000..3fda7ae --- /dev/null +++ b/backend/test/routes/shared.auth-providers.routes.test.js @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' + +// Same pattern as the other route tests — keep the websocket plugin no-op so +// buildPublic doesn't try to open real WS upgrades. +vi.mock('../../src/plugins/websocket.js', () => ({ + sendToUser: vi.fn(() => false), + sendToSessionParticipant: vi.fn(() => false), + registerWebSocketPlugin: vi.fn(async () => {}), + registerWebSocketRoute: vi.fn(), + isUserOnlineWs: vi.fn(() => false), + getSessionConnections: vi.fn(() => ({})), +})) + +vi.mock('../../src/services/notification.service.js', () => ({ + sendPushNotification: vi.fn(async () => true), + registerDeviceToken: vi.fn(async () => {}), +})) + +describe('GET /api/shared/auth-providers (Phase 4)', () => { + // Snapshot env so we can mutate freely and restore. + const ENV_KEYS = [ + 'GOOGLE_OAUTH_CLIENT_ID', + 'GOOGLE_OAUTH_CLIENT_SECRET', + 'APPLE_OAUTH_CLIENT_ID', + 'APPLE_OAUTH_TEAM_ID', + 'APPLE_OAUTH_KEY_ID', + 'APPLE_OAUTH_PRIVATE_KEY', + ] + let original + + beforeEach(() => { + original = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])) + for (const k of ENV_KEYS) delete process.env[k] + }) + + afterEach(() => { + for (const k of ENV_KEYS) { + if (original[k] === undefined) delete process.env[k] + else process.env[k] = original[k] + } + }) + + it('returns enabled:false for google + apple when env vars are unset; phone always true', async () => { + // Re-import service to drop the module-load cache, then reset its in-memory cache. + const svc = await import('../../src/services/auth-providers.service.js') + svc._resetAuthProvidersCache() + + const { buildPublic } = await import('../helpers/server.js') + const app = await buildPublic() + try { + const res = await app.inject({ method: 'GET', url: '/api/shared/auth-providers' }) + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.success).toBe(true) + expect(body.data.google.enabled).toBe(false) + expect(body.data.apple.enabled).toBe(false) + expect(body.data.phone.enabled).toBe(true) + } finally { + await app.close() + } + }) + + it('returns enabled:true for google + apple when all env vars are set', async () => { + process.env.GOOGLE_OAUTH_CLIENT_ID = 'id' + process.env.GOOGLE_OAUTH_CLIENT_SECRET = 'secret' + process.env.APPLE_OAUTH_CLIENT_ID = 'apple-id' + process.env.APPLE_OAUTH_TEAM_ID = 'team' + process.env.APPLE_OAUTH_KEY_ID = 'key' + process.env.APPLE_OAUTH_PRIVATE_KEY = 'priv' + + const svc = await import('../../src/services/auth-providers.service.js') + svc._resetAuthProvidersCache() + + const { buildPublic } = await import('../helpers/server.js') + const app = await buildPublic() + try { + const res = await app.inject({ method: 'GET', url: '/api/shared/auth-providers' }) + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.data.google.enabled).toBe(true) + expect(body.data.apple.enabled).toBe(true) + expect(body.data.phone.enabled).toBe(true) + } finally { + await app.close() + } + }) +}) diff --git a/backend/test/services/payment.service.test.js b/backend/test/services/payment.service.test.js index 19b8915..e514315 100644 --- a/backend/test/services/payment.service.test.js +++ b/backend/test/services/payment.service.test.js @@ -38,8 +38,9 @@ describe('payment.service', () => { expect(session.customer_id).toBe(customer.id) expect(session.duration_minutes).toBe(15) expect(session.amount).toBe(30000) - expect(session.is_free_trial).toBe(false) + expect(session.is_first_session_discount).toBe(false) expect(session.is_extension).toBe(false) + expect(session.mode).toBe('chat') expect(new Date(session.expires_at).getTime()).toBeGreaterThan(before) // Verify it's actually persisted (not just returned from the INSERT) diff --git a/backend/test/services/session-timer.service.test.js b/backend/test/services/session-timer.service.test.js new file mode 100644 index 0000000..46f199b --- /dev/null +++ b/backend/test/services/session-timer.service.test.js @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' + +// Capture calls to sendToSessionParticipant so we can assert the 3-min warning event. +vi.mock('../../src/plugins/websocket.js', () => ({ + sendToUser: vi.fn(() => true), + sendToSessionParticipant: vi.fn(() => true), + registerWebSocketPlugin: vi.fn(), + registerWebSocketRoute: vi.fn(), + isUserOnlineWs: vi.fn(() => true), + getSessionConnections: vi.fn(() => ({})), +})) + +vi.mock('../../src/services/notification.service.js', () => ({ + sendPushNotification: vi.fn(async () => true), + registerDeviceToken: vi.fn(async () => {}), +})) + +vi.mock('../../src/plugins/valkey.js', () => ({ + publish: vi.fn(async () => {}), + subscribe: vi.fn(() => () => {}), +})) + +const { sendToSessionParticipant } = await import('../../src/plugins/websocket.js') +const { startSessionTimer, clearSessionTimer } = await import('../../src/services/session-timer.service.js') +const { WsMessage, UserType } = await import('../../src/constants.js') + +describe('session-timer 3-minute warning (Phase 4)', () => { + beforeEach(() => { + vi.useFakeTimers() + sendToSessionParticipant.mockClear() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('emits session_warning kind:three_minutes_left exactly once at the 3-min mark', async () => { + const sessionId = 'sess-3min-test' + const expiresAt = new Date(Date.now() + 5 * 60_000) // 5 minutes from now + + startSessionTimer(sessionId, expiresAt) + + // Advance 1 min 59 s — well before the 2-min mark when the 3-min warning fires. + await vi.advanceTimersByTimeAsync(60_000 + 59_000) + const warnCallsEarly = sendToSessionParticipant.mock.calls.filter( + ([, , data]) => data?.type === WsMessage.SESSION_WARNING, + ) + expect(warnCallsEarly).toHaveLength(0) + + // Cross the 3-min-left threshold. 5 min total - 3 min = warning fires at t=2:00. + await vi.advanceTimersByTimeAsync(2_000) + // sendToSessionParticipant signature: (sessionId, userType, data) + const warnCalls = sendToSessionParticipant.mock.calls.filter( + ([, , data]) => data?.type === WsMessage.SESSION_WARNING, + ) + expect(warnCalls).toHaveLength(1) + const [calledSessionId, userType, data] = warnCalls[0] + expect(calledSessionId).toBe(sessionId) + expect(userType).toBe(UserType.CUSTOMER) + expect(data.kind).toBe('three_minutes_left') + expect(data.session_id).toBe(sessionId) + + // Cleanup before expiry hits. + clearSessionTimer(sessionId) + }) + + it('does NOT re-fire the 3-min warning when the timer is rescheduled (e.g. extension)', async () => { + const sessionId = 'sess-rescheduled' + const initial = new Date(Date.now() + 5 * 60_000) + startSessionTimer(sessionId, initial) + + // Cross the 3-min mark on the original schedule. + await vi.advanceTimersByTimeAsync(2 * 60_000 + 1_000) + let warnCalls = sendToSessionParticipant.mock.calls.filter( + ([, , data]) => data?.type === WsMessage.SESSION_WARNING, + ) + expect(warnCalls).toHaveLength(1) + + // Extension reschedules — give a new 5-min window. The 3-min warning must NOT fire again. + const extended = new Date(Date.now() + 5 * 60_000) + startSessionTimer(sessionId, extended) + await vi.advanceTimersByTimeAsync(2 * 60_000 + 1_000) + + warnCalls = sendToSessionParticipant.mock.calls.filter( + ([, , data]) => data?.type === WsMessage.SESSION_WARNING, + ) + expect(warnCalls).toHaveLength(1) // still 1, no double-fire + + clearSessionTimer(sessionId) + }) +}) diff --git a/control_center/src/pages/settings/SettingsPage.jsx b/control_center/src/pages/settings/SettingsPage.jsx index 9b756ba..7d0c3e7 100644 --- a/control_center/src/pages/settings/SettingsPage.jsx +++ b/control_center/src/pages/settings/SettingsPage.jsx @@ -1,3 +1,4 @@ +import { useState, useEffect } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { apiClient } from '../../core/api/api-client' import { ExtensionTimeoutAction } from '../../core/constants' @@ -116,6 +117,36 @@ const updateExtensionDefaultAction = async (extension_default_action_on_timeout) return res.data.data } +// Phase 4: First-session discount +const fetchFirstSessionDiscount = async () => { + const res = await apiClient.get('/internal/config/first-session-discount') + return res.data.data +} +const updateFirstSessionDiscount = async (patch) => { + const res = await apiClient.patch('/internal/config/first-session-discount', patch) + return res.data.data +} + +// Phase 4: Pricing tier groups +const fetchPricingTiers = async () => { + const res = await apiClient.get('/internal/config/pricing-tiers') + return res.data.data +} +const updatePricingTier = async ({ mode, tiers }) => { + const res = await apiClient.patch(`/internal/config/pricing-tiers/${mode}`, { tiers }) + return res.data.data +} + +// Phase 4: Support handles +const fetchSupportHandles = async () => { + const res = await apiClient.get('/internal/config/support-handles') + return res.data.data +} +const updateSupportHandles = async (patch) => { + const res = await apiClient.patch('/internal/config/support-handles', patch) + return res.data.data +} + export default function SettingsPage() { const queryClient = useQueryClient() const { data, isLoading } = useQuery({ queryKey: ['config-anonymity'], queryFn: fetchAnonymityConfig }) @@ -225,9 +256,40 @@ export default function SettingsPage() { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-extension-default-action'] }), }) + // Phase 4: First-session discount + const { data: fsdData, isLoading: fsdLoading } = useQuery({ + queryKey: ['config-first-session-discount'], + queryFn: fetchFirstSessionDiscount, + }) + const fsdMutation = useMutation({ + mutationFn: updateFirstSessionDiscount, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-first-session-discount'] }), + }) + + // Phase 4: Pricing tier groups + const { data: ptData, isLoading: ptLoading } = useQuery({ + queryKey: ['config-pricing-tiers'], + queryFn: fetchPricingTiers, + }) + const ptMutation = useMutation({ + mutationFn: updatePricingTier, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-pricing-tiers'] }), + }) + + // Phase 4: Support handles + const { data: shData, isLoading: shLoading } = useQuery({ + queryKey: ['config-support-handles'], + queryFn: fetchSupportHandles, + }) + const shMutation = useMutation({ + mutationFn: updateSupportHandles, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-support-handles'] }), + }) + if ( isLoading || maxLoading || ftLoading || etLoading || eeLoading || mpLoading || senLoading || - pbtLoading || pstLoading || rctLoading || edaLoading + pbtLoading || pstLoading || rctLoading || edaLoading || + fsdLoading || ptLoading || shLoading ) return
Loading...
return ( @@ -493,6 +555,193 @@ export default function SettingsPage() { {edaMutation.isError &&

Gagal menyimpan.

} + + {/* Phase 4: First-session discount */} +
+

Diskon Sesi Pertama (Phase 4)

+

Diskon untuk sesi pertama customer yang sudah terverifikasi nomor HP. Menggantikan free trial Phase 3.

+ +
+ + { + const v = parseInt(e.target.value, 10) + if (Number.isFinite(v) && v >= 0) fsdMutation.mutate({ actual_price_idr: v }) + }} + disabled={fsdMutation.isPending} + style={{ width: 120 }} + /> +
+
+ + { + const v = parseInt(e.target.value, 10) + if (Number.isFinite(v) && v >= 0) fsdMutation.mutate({ gimmick_price_idr: v }) + }} + disabled={fsdMutation.isPending} + style={{ width: 120 }} + /> +
+
+ + { + const v = parseInt(e.target.value, 10) + if (Number.isFinite(v) && v >= 1) fsdMutation.mutate({ duration_minutes: v }) + }} + disabled={fsdMutation.isPending} + style={{ width: 80 }} + /> +
+
+ Mode yang dapat diskon: + {['chat', 'call'].map(m => ( + + ))} +
+ {fsdMutation.isError &&

Gagal menyimpan.

} +
+ + {/* Phase 4: Pricing tier groups (mock) */} +
+

Tier Harga (Mock — Phase 4)

+

Daftar tier untuk chat dan voice call. JSON harus berupa array of {`{ id, minutes, price_idr, tag? }`}. Pricing masih di-mock — Xendit nyusul di phase berikutnya.

+ {['chat', 'call'].map((mode) => ( + ptMutation.mutate({ mode, tiers })} + isPending={ptMutation.isPending} + /> + ))} + {ptMutation.isError &&

Gagal menyimpan tier — pastikan JSON valid.

} +
+ + {/* Phase 4: Support handles */} +
+

Support Handles (Tanya Admin)

+

Deeplink WA + Telegram untuk sheet "Tanya Admin" di client_app.

+
+ + shMutation.mutate({ wa: { label: e.target.value } })} + disabled={shMutation.isPending} + style={{ width: 240 }} + /> +
+
+ + shMutation.mutate({ wa: { deeplink: e.target.value } })} + disabled={shMutation.isPending} + style={{ width: 360 }} + placeholder="https://wa.me/62..." + /> +
+
+ + shMutation.mutate({ telegram: { label: e.target.value } })} + disabled={shMutation.isPending} + style={{ width: 240 }} + /> +
+
+ + shMutation.mutate({ telegram: { deeplink: e.target.value } })} + disabled={shMutation.isPending} + style={{ width: 360 }} + placeholder="https://t.me/..." + /> +
+ {shMutation.isError &&

Gagal menyimpan.

} +
+ + ) +} + +// Local helper — JSON-validated textarea editor for one mode's tier list. Keeps the +// editing UX simple (paste JSON, hit Save) without forcing per-row form widgets. +function PricingTierEditor({ mode, tiers, onSave, isPending }) { + const initial = JSON.stringify(tiers, null, 2) + const [draft, setDraft] = useState(initial) + const [error, setError] = useState(null) + + // Reset draft when the upstream tiers change (e.g. after a successful save). + useEffect(() => { + setDraft(JSON.stringify(tiers, null, 2)) + setError(null) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(tiers)]) + + const handleSave = () => { + try { + const parsed = JSON.parse(draft) + if (!Array.isArray(parsed)) throw new Error('expected an array') + for (const t of parsed) { + if (typeof t.id !== 'string' || typeof t.minutes !== 'number' || typeof t.price_idr !== 'number') { + throw new Error('each tier needs id (string), minutes (number), price_idr (number)') + } + } + setError(null) + onSave(parsed) + } catch (e) { + setError(String(e.message || e)) + } + } + + return ( +
+

{mode === 'chat' ? 'Chat tiers' : 'Voice call tiers'}

+