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:
@@ -5,11 +5,14 @@ import { sharedAuthRoutes } from './routes/public/shared.auth.routes.js'
|
|||||||
import { clientAuthRoutes } from './routes/public/client.auth.routes.js'
|
import { clientAuthRoutes } from './routes/public/client.auth.routes.js'
|
||||||
import { mitraAuthRoutes } from './routes/public/mitra.auth.routes.js'
|
import { mitraAuthRoutes } from './routes/public/mitra.auth.routes.js'
|
||||||
import { sharedConfigRoutes } from './routes/public/shared.config.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 { mitraStatusRoutes } from './routes/public/mitra.status.routes.js'
|
||||||
import { mitraChatRoutes } from './routes/public/mitra.chat.routes.js'
|
import { mitraChatRoutes } from './routes/public/mitra.chat.routes.js'
|
||||||
import { clientChatRoutes } from './routes/public/client.chat.routes.js'
|
import { clientChatRoutes } from './routes/public/client.chat.routes.js'
|
||||||
import { clientPaymentRoutes } from './routes/public/client.payment.routes.js'
|
import { clientPaymentRoutes } from './routes/public/client.payment.routes.js'
|
||||||
import { clientMitraAvailabilityRoutes } from './routes/public/client.mitra-availability.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 { sharedChatRoutes } from './routes/public/shared.chat.routes.js'
|
||||||
import { errorHandler } from './plugins/error-handler.js'
|
import { errorHandler } from './plugins/error-handler.js'
|
||||||
import { registerWebSocketPlugin, registerWebSocketRoute } from './plugins/websocket.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(sharedAuthRoutes, { prefix: '/api/shared/auth' })
|
||||||
app.register(sharedConfigRoutes, { prefix: '/api/shared/config' })
|
app.register(sharedConfigRoutes, { prefix: '/api/shared/config' })
|
||||||
|
app.register(sharedAuthProvidersRoutes, { prefix: '/api/shared/auth-providers' })
|
||||||
app.register(sharedChatRoutes, { prefix: '/api/shared' })
|
app.register(sharedChatRoutes, { prefix: '/api/shared' })
|
||||||
app.register(clientAuthRoutes, { prefix: '/api/client/auth' })
|
app.register(clientAuthRoutes, { prefix: '/api/client/auth' })
|
||||||
app.register(mitraAuthRoutes, { prefix: '/api/mitra/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(clientChatRoutes, { prefix: '/api/client/chat' })
|
||||||
app.register(clientPaymentRoutes, { prefix: '/api/client/payment-sessions' })
|
app.register(clientPaymentRoutes, { prefix: '/api/client/payment-sessions' })
|
||||||
app.register(clientMitraAvailabilityRoutes, { prefix: '/api/client/mitra-availability' })
|
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)
|
// WebSocket route (registered at app level, not prefixed)
|
||||||
registerWebSocketRoute(app)
|
registerWebSocketRoute(app)
|
||||||
|
|||||||
@@ -48,11 +48,20 @@ export const ExtensionStatus = Object.freeze({
|
|||||||
|
|
||||||
// Customer transaction types
|
// Customer transaction types
|
||||||
export const TransactionType = Object.freeze({
|
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',
|
PAID: 'paid',
|
||||||
EXTENSION: 'extension',
|
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
|
// Payment session lifecycle
|
||||||
export const PaymentSessionStatus = Object.freeze({
|
export const PaymentSessionStatus = Object.freeze({
|
||||||
PENDING: 'pending',
|
PENDING: 'pending',
|
||||||
@@ -144,6 +153,9 @@ export const WsMessage = Object.freeze({
|
|||||||
|
|
||||||
// Session lifecycle
|
// Session lifecycle
|
||||||
SESSION_TIMER: 'session_timer',
|
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_EXPIRED: 'session_expired',
|
||||||
SESSION_CLOSING: 'session_closing',
|
SESSION_CLOSING: 'session_closing',
|
||||||
SESSION_COMPLETED: 'session_completed',
|
SESSION_COMPLETED: 'session_completed',
|
||||||
|
|||||||
@@ -549,6 +549,111 @@ const migrate = async () => {
|
|||||||
ON CONFLICT (key) DO NOTHING
|
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.')
|
console.log('Migration complete.')
|
||||||
await sql.end()
|
await sql.end()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import {
|
|||||||
getReturningChatConfirmationTimeoutSeconds, setReturningChatConfirmationTimeoutSeconds,
|
getReturningChatConfirmationTimeoutSeconds, setReturningChatConfirmationTimeoutSeconds,
|
||||||
getExtensionDefaultActionOnTimeout, setExtensionDefaultActionOnTimeout,
|
getExtensionDefaultActionOnTimeout, setExtensionDefaultActionOnTimeout,
|
||||||
getPairingBlastTimeoutSeconds, setPairingBlastTimeoutSeconds,
|
getPairingBlastTimeoutSeconds, setPairingBlastTimeoutSeconds,
|
||||||
|
getFirstSessionDiscountConfig, setFirstSessionDiscountConfig,
|
||||||
|
getSupportHandles, setSupportHandles,
|
||||||
|
getPricingTierGroups, setPricingTierGroup,
|
||||||
} from '../../services/config.service.js'
|
} from '../../services/config.service.js'
|
||||||
|
|
||||||
const attachCcUser = async (request, reply) => {
|
const attachCcUser = async (request, reply) => {
|
||||||
@@ -284,4 +287,104 @@ export const internalConfigRoutes = async (app) => {
|
|||||||
await publishConfigInvalidate('pairing_blast_timeout_seconds')
|
await publishConfigInvalidate('pairing_blast_timeout_seconds')
|
||||||
return reply.send({ success: true, data: config })
|
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 })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ const resolveCustomer = async (request, reply) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const clientChatRoutes = async (app) => {
|
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) => {
|
app.get('/pricing', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||||
const pricing = await getPricingForCustomer(request.customer.id)
|
const pricing = await getPricingForCustomer(request.customer.id)
|
||||||
return reply.send({ success: true, data: pricing })
|
return reply.send({ success: true, data: pricing })
|
||||||
@@ -171,7 +174,7 @@ export const clientChatRoutes = async (app) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Extension request REQUIRES `extension_payment_session_id`.
|
* 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.
|
* Pricing/duration come from the payment session via the extension service.
|
||||||
*/
|
*/
|
||||||
app.post('/session/:sessionId/extend', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
app.post('/session/:sessionId/extend', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||||
|
|||||||
60
backend/src/routes/public/client.onboarding.routes.js
Normal file
60
backend/src/routes/public/client.onboarding.routes.js
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -7,11 +7,14 @@ import {
|
|||||||
getPaymentSession,
|
getPaymentSession,
|
||||||
} from '../../services/payment.service.js'
|
} from '../../services/payment.service.js'
|
||||||
import {
|
import {
|
||||||
isCustomerEligibleForFreeTrial,
|
isCustomerEligibleForFirstSessionDiscount,
|
||||||
isValidTier,
|
isValidTier,
|
||||||
getPriceTiers,
|
findTier,
|
||||||
} from '../../services/pricing.service.js'
|
} 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) => {
|
const resolveCustomer = async (request, reply) => {
|
||||||
if (request.auth?.userType !== UserType.CUSTOMER) {
|
if (request.auth?.userType !== UserType.CUSTOMER) {
|
||||||
@@ -30,6 +33,25 @@ const resolveCustomer = async (request, reply) => {
|
|||||||
request.customer = customer
|
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).
|
* Payment session lifecycle (mocked — no Xendit yet).
|
||||||
*
|
*
|
||||||
@@ -39,12 +61,13 @@ const resolveCustomer = async (request, reply) => {
|
|||||||
* GET /api/client/payment-sessions/:id
|
* GET /api/client/payment-sessions/:id
|
||||||
*/
|
*/
|
||||||
export const clientPaymentRoutes = async (app) => {
|
export const clientPaymentRoutes = async (app) => {
|
||||||
// Create a payment session (status = pending). Free-trial logic is server-side: if the
|
// Create a payment session (status = pending). First-session-discount is server-authoritative:
|
||||||
// customer is eligible AND this is NOT an extension, amount is forced to 0 and
|
// if the customer is eligible AND this is NOT an extension AND mode is in the configured
|
||||||
// is_free_trial = true regardless of what the client passes.
|
// modes list, amount is forced to the configured discount price.
|
||||||
app.post('/', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
app.post('/', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||||
const {
|
const {
|
||||||
duration_minutes,
|
duration_minutes,
|
||||||
|
mode = SessionMode.CHAT,
|
||||||
targeted_mitra_id = null,
|
targeted_mitra_id = null,
|
||||||
is_extension = false,
|
is_extension = false,
|
||||||
} = request.body ?? {}
|
} = request.body ?? {}
|
||||||
@@ -55,33 +78,44 @@ export const clientPaymentRoutes = async (app) => {
|
|||||||
error: { code: 'VALIDATION_ERROR', message: 'duration_minutes must be a positive number' },
|
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 isFirstSessionDiscount = false
|
||||||
let isFreeTrial = false
|
|
||||||
let amount
|
let amount
|
||||||
|
|
||||||
if (!is_extension) {
|
if (!is_extension) {
|
||||||
const eligible = await isCustomerEligibleForFreeTrial(request.customer.id)
|
const eligible = await isCustomerEligibleForFirstSessionDiscount(request.customer.id)
|
||||||
if (eligible) {
|
if (eligible) {
|
||||||
isFreeTrial = true
|
const discount = await readDiscountConfig()
|
||||||
amount = 0
|
// 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) {
|
if (!isFirstSessionDiscount) {
|
||||||
// Resolve amount from the price tiers (duration-keyed). The client passes
|
// Resolve amount from the configured tier list for the requested mode.
|
||||||
// duration_minutes; we look up the matching tier to get the canonical price.
|
const tier = await findTier({ mode, durationMinutes: duration_minutes })
|
||||||
const tiers = await getPriceTiers()
|
|
||||||
const tier = tiers.find((t) => t.duration_minutes === duration_minutes)
|
|
||||||
if (!tier) {
|
if (!tier) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
success: false,
|
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
|
amount = tier.price_idr
|
||||||
// Sanity check (defense-in-depth) — duration+price should match a known tier.
|
if (!(await isValidTier({ mode, durationMinutes: duration_minutes, priceIdr: amount }))) {
|
||||||
if (!(await isValidTier(duration_minutes, amount))) {
|
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
success: false,
|
success: false,
|
||||||
error: { code: 'INVALID_TIER', message: 'Invalid price tier selection' },
|
error: { code: 'INVALID_TIER', message: 'Invalid price tier selection' },
|
||||||
@@ -93,9 +127,10 @@ export const clientPaymentRoutes = async (app) => {
|
|||||||
customerId: request.customer.id,
|
customerId: request.customer.id,
|
||||||
durationMinutes: duration_minutes,
|
durationMinutes: duration_minutes,
|
||||||
amount,
|
amount,
|
||||||
isFreeTrial,
|
isFirstSessionDiscount,
|
||||||
isExtension: Boolean(is_extension),
|
isExtension: Boolean(is_extension),
|
||||||
targetedMitraId: targeted_mitra_id || null,
|
targetedMitraId: targeted_mitra_id || null,
|
||||||
|
mode,
|
||||||
})
|
})
|
||||||
|
|
||||||
return reply.code(201).send({
|
return reply.code(201).send({
|
||||||
@@ -104,8 +139,9 @@ export const clientPaymentRoutes = async (app) => {
|
|||||||
id: session.id,
|
id: session.id,
|
||||||
amount: session.amount,
|
amount: session.amount,
|
||||||
duration_minutes: session.duration_minutes,
|
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,
|
is_extension: session.is_extension,
|
||||||
|
mode: session.mode,
|
||||||
targeted_mitra_id: session.targeted_mitra_id,
|
targeted_mitra_id: session.targeted_mitra_id,
|
||||||
expires_at: session.expires_at,
|
expires_at: session.expires_at,
|
||||||
status: session.status,
|
status: session.status,
|
||||||
|
|||||||
14
backend/src/routes/public/client.support.routes.js
Normal file
14
backend/src/routes/public/client.support.routes.js
Normal file
@@ -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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
14
backend/src/routes/public/shared.auth-providers.routes.js
Normal file
14
backend/src/routes/public/shared.auth-providers.routes.js
Normal file
@@ -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() })
|
||||||
|
})
|
||||||
|
}
|
||||||
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 }
|
return { max_customers_per_mitra: value }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Phase 3 config ---
|
// --- Phase 4: First-session discount (replaces Phase 3 free-trial config) ---
|
||||||
|
|
||||||
export const getFreeTrialConfig = async () => {
|
export const getFirstSessionDiscountConfig = async () => {
|
||||||
const [enabledRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_enabled'`
|
const rows = await sql`
|
||||||
const [durationRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_duration_minutes'`
|
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 {
|
return {
|
||||||
enabled: enabledRow?.value?.value ?? false,
|
enabled: byKey.first_session_discount_enabled ?? true,
|
||||||
duration_minutes: durationRow?.value?.value ?? 5,
|
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 }) => {
|
export const setFreeTrialConfig = async ({ enabled, duration_minutes }) => {
|
||||||
if (enabled !== undefined) {
|
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: '' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setSupportHandles = async ({ wa, telegram }) => {
|
||||||
|
const current = await getSupportHandles()
|
||||||
|
const next = {
|
||||||
|
wa: { ...current.wa, ...(wa || {}) },
|
||||||
|
telegram: { ...current.telegram, ...(telegram || {}) },
|
||||||
|
}
|
||||||
await sql`
|
await sql`
|
||||||
INSERT INTO app_config (key, value, updated_at)
|
INSERT INTO app_config (key, value, updated_at)
|
||||||
VALUES ('free_trial_enabled', ${sql.json({ value: enabled })}, NOW())
|
VALUES ('support_handles_json', ${sql.json(next)}, NOW())
|
||||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||||
`
|
`
|
||||||
|
return next
|
||||||
}
|
}
|
||||||
if (duration_minutes !== undefined) {
|
|
||||||
|
// --- 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`
|
await sql`
|
||||||
INSERT INTO app_config (key, value, updated_at)
|
INSERT INTO app_config (key, value, updated_at)
|
||||||
VALUES ('free_trial_duration_minutes', ${sql.json({ value: duration_minutes })}, NOW())
|
VALUES (${key}, ${sql.json({ tiers })}, NOW())
|
||||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||||
`
|
`
|
||||||
}
|
return getPricingTierGroups()
|
||||||
return getFreeTrialConfig()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getExtensionTimeoutConfig = async () => {
|
export const getExtensionTimeoutConfig = async () => {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const getExtensionTimeoutAction = async () => {
|
|||||||
* - belong to this customer
|
* - belong to this customer
|
||||||
* - be in `confirmed` status (not yet consumed)
|
* - be in `confirmed` status (not yet consumed)
|
||||||
* - have `is_extension = true`
|
* - 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
|
* The payment session is NOT consumed at request time. It is consumed at approval moment
|
||||||
* (mitra explicit accept OR auto-approve fires).
|
* (mitra explicit accept OR auto-approve fires).
|
||||||
@@ -83,9 +83,9 @@ export const requestExtension = async (sessionId, customerId, { duration_minutes
|
|||||||
code: 'INVALID_STATE', statusCode: 409,
|
code: 'INVALID_STATE', statusCode: 409,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (paySession.is_free_trial) {
|
if (paySession.is_first_session_discount) {
|
||||||
throw Object.assign(new Error('Free trial is not available for extensions'), {
|
throw Object.assign(new Error('First-session discount is not available for extensions'), {
|
||||||
code: 'FREE_TRIAL_NOT_ALLOWED', statusCode: 400,
|
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.
|
* 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.
|
* 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
|
* `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.
|
// Create session sourced from the payment session.
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
INSERT INTO chat_sessions (
|
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 (
|
VALUES (
|
||||||
${customerId}, ${SessionStatus.PENDING_ACCEPTANCE},
|
${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}
|
${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
|
// 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,
|
request_type: PairingRequestType.GENERAL,
|
||||||
created_at: session.created_at,
|
created_at: session.created_at,
|
||||||
duration_minutes: session.duration_minutes,
|
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,
|
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.
|
// Create session sourced from the payment session, status = pending_acceptance.
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
INSERT INTO chat_sessions (
|
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 (
|
VALUES (
|
||||||
${customerId}, ${SessionStatus.PENDING_ACCEPTANCE},
|
${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}
|
${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
|
// Single notification to the targeted mitra
|
||||||
@@ -330,7 +330,7 @@ export const createTargetedPairingRequest = async (customerId, { paymentSessionI
|
|||||||
request_type: PairingRequestType.RETURNING,
|
request_type: PairingRequestType.RETURNING,
|
||||||
created_at: session.created_at,
|
created_at: session.created_at,
|
||||||
duration_minutes: session.duration_minutes,
|
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,
|
topic_sensitivity: session.topic_sensitivity,
|
||||||
confirmation_timeout_seconds: returning_chat_confirmation_timeout_seconds,
|
confirmation_timeout_seconds: returning_chat_confirmation_timeout_seconds,
|
||||||
})
|
})
|
||||||
@@ -399,12 +399,12 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
|
|||||||
ELSE NULL
|
ELSE NULL
|
||||||
END
|
END
|
||||||
WHERE id = ${sessionId}
|
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
|
// Record transaction
|
||||||
if (activeSession.duration_minutes) {
|
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`
|
await sql`
|
||||||
INSERT INTO customer_transactions (customer_id, session_id, type, amount)
|
INSERT INTO customer_transactions (customer_id, session_id, type, amount)
|
||||||
VALUES (${activeSession.customer_id}, ${sessionId}, ${txType}, ${activeSession.price || 0})
|
VALUES (${activeSession.customer_id}, ${sessionId}, ${txType}, ${activeSession.price || 0})
|
||||||
@@ -787,7 +787,7 @@ export const getPendingRequestsForMitra = async (mitraId) => {
|
|||||||
SELECT
|
SELECT
|
||||||
cs.id AS session_id,
|
cs.id AS session_id,
|
||||||
cs.duration_minutes,
|
cs.duration_minutes,
|
||||||
cs.is_free_trial,
|
cs.is_first_session_discount,
|
||||||
cs.topic_sensitivity,
|
cs.topic_sensitivity,
|
||||||
cs.created_at,
|
cs.created_at,
|
||||||
CASE
|
CASE
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getDb } from '../db/client.js'
|
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 { recordFailure } from './pairing-failure.service.js'
|
||||||
import { sendToUser } from '../plugins/websocket.js'
|
import { sendToUser } from '../plugins/websocket.js'
|
||||||
import { sendPushNotification } from './notification.service.js'
|
import { sendPushNotification } from './notification.service.js'
|
||||||
@@ -15,14 +15,19 @@ const getPaymentSessionTimeoutMinutes = async () => {
|
|||||||
/**
|
/**
|
||||||
* Create a new payment session in `pending` status.
|
* Create a new payment session in `pending` status.
|
||||||
* Reads `payment_session_timeout_minutes` from config to compute expires_at.
|
* 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 ({
|
export const createPaymentSession = async ({
|
||||||
customerId,
|
customerId,
|
||||||
durationMinutes,
|
durationMinutes,
|
||||||
amount,
|
amount,
|
||||||
isFreeTrial = false,
|
isFirstSessionDiscount = false,
|
||||||
isExtension = false,
|
isExtension = false,
|
||||||
targetedMitraId = null,
|
targetedMitraId = null,
|
||||||
|
mode = SessionMode.CHAT,
|
||||||
}) => {
|
}) => {
|
||||||
if (!customerId) {
|
if (!customerId) {
|
||||||
throw Object.assign(new Error('customerId is required'), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
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) {
|
if (typeof amount !== 'number' || amount < 0) {
|
||||||
throw Object.assign(new Error('amount must be a non-negative number'), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
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 ttlMinutes = await getPaymentSessionTimeoutMinutes()
|
||||||
|
|
||||||
const [row] = await sql`
|
const [row] = await sql`
|
||||||
INSERT INTO payment_sessions (
|
INSERT INTO payment_sessions (
|
||||||
customer_id, amount, duration_minutes, is_free_trial, is_extension,
|
customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
|
||||||
status, targeted_mitra_id, expires_at
|
status, targeted_mitra_id, mode, expires_at
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
${customerId}, ${amount}, ${durationMinutes}, ${isFreeTrial}, ${isExtension},
|
${customerId}, ${amount}, ${durationMinutes}, ${isFirstSessionDiscount}, ${isExtension},
|
||||||
${PaymentSessionStatus.PENDING}, ${targetedMitraId},
|
${PaymentSessionStatus.PENDING}, ${targetedMitraId}, ${mode},
|
||||||
NOW() + (${ttlMinutes} || ' minutes')::interval
|
NOW() + (${ttlMinutes} || ' minutes')::interval
|
||||||
)
|
)
|
||||||
RETURNING id, customer_id, amount, duration_minutes, is_free_trial, is_extension,
|
RETURNING id, customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
|
||||||
status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
|
mode, status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
|
||||||
`
|
`
|
||||||
|
|
||||||
return row
|
return row
|
||||||
@@ -87,8 +95,8 @@ export const confirmPaymentSession = async (paymentSessionId, customerId) => {
|
|||||||
UPDATE payment_sessions
|
UPDATE payment_sessions
|
||||||
SET status = ${PaymentSessionStatus.CONFIRMED}, confirmed_at = NOW()
|
SET status = ${PaymentSessionStatus.CONFIRMED}, confirmed_at = NOW()
|
||||||
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.PENDING}
|
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.PENDING}
|
||||||
RETURNING id, customer_id, amount, duration_minutes, is_free_trial, is_extension,
|
RETURNING id, customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
|
||||||
status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
|
mode, status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
|
||||||
`
|
`
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
throw Object.assign(new Error('Payment session state changed during confirm'), { code: 'CONFLICT', statusCode: 409 })
|
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) => {
|
export const getPaymentSession = async (id) => {
|
||||||
const [row] = await sql`
|
const [row] = await sql`
|
||||||
SELECT id, customer_id, amount, duration_minutes, is_free_trial, is_extension,
|
SELECT id, customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
|
||||||
status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
|
mode, status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
|
||||||
FROM payment_sessions
|
FROM payment_sessions
|
||||||
WHERE id = ${id}
|
WHERE id = ${id}
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -1,75 +1,175 @@
|
|||||||
import { getDb } from '../db/client.js'
|
import { getDb } from '../db/client.js'
|
||||||
|
import { SessionStatus } from '../constants.js'
|
||||||
|
|
||||||
const sql = getDb()
|
const sql = getDb()
|
||||||
|
|
||||||
// Default tiers as fallback
|
// Default tiers as fallback (used if app_config row is missing). Match the seed
|
||||||
const DEFAULT_TIERS = [
|
// values in migrate.js so a missing row never breaks pricing in the wild.
|
||||||
{ duration_minutes: 1, price: 5000, label: '1 Menit (Test)' },
|
const DEFAULT_CHAT_TIERS = [
|
||||||
{ duration_minutes: 15, price: 30000, label: '15 Menit' },
|
{ id: '5', minutes: 5, price_idr: 5000, tag: null },
|
||||||
{ duration_minutes: 30, price: 60000, label: '30 Menit' },
|
{ id: '12', minutes: 12, price_idr: 12000, tag: 'paling pas' },
|
||||||
{ duration_minutes: 45, price: 100000, label: '45 Menit' },
|
{ id: '30', minutes: 30, price_idr: 25000, tag: 'hemat' },
|
||||||
{ duration_minutes: 60, price: 150000, label: '60 Menit' },
|
{ id: '60', minutes: 60, price_idr: 45000, tag: null },
|
||||||
{ duration_minutes: 1440, price: 250000, label: '24 Jam' },
|
{ id: '120', minutes: 120, price_idr: 80000, tag: 'best deal' },
|
||||||
]
|
]
|
||||||
|
const DEFAULT_CALL_TIERS = [
|
||||||
export const getPriceTiers = async () => {
|
{ id: '10', minutes: 10, price_idr: 9000, tag: null },
|
||||||
const [row] = await sql`SELECT value FROM app_config WHERE key = 'price_tiers'`
|
{ id: '20', minutes: 20, price_idr: 17000, tag: 'paling pas' },
|
||||||
return row?.value?.tiers ?? DEFAULT_TIERS
|
{ 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 readChatTiers = async () => {
|
||||||
const tiers = await getPriceTiers()
|
const [row] = await sql`SELECT value FROM app_config WHERE key = 'pricing_chat_tiers_json'`
|
||||||
return tiers.some(
|
return row?.value?.tiers ?? DEFAULT_CHAT_TIERS
|
||||||
(t) => t.duration_minutes === durationMinutes && t.price === price
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getFreeTrial = async () => {
|
const readCallTiers = async () => {
|
||||||
const [enabledRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_enabled'`
|
const [row] = await sql`SELECT value FROM app_config WHERE key = 'pricing_call_tiers_json'`
|
||||||
const [durationRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_duration_minutes'`
|
return row?.value?.tiers ?? DEFAULT_CALL_TIERS
|
||||||
return {
|
|
||||||
enabled: enabledRow?.value?.value ?? false,
|
|
||||||
duration_minutes: durationRow?.value?.value ?? 5,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isCustomerEligibleForFreeTrial = async (customerId) => {
|
const readDiscountConfig = async () => {
|
||||||
const freeTrial = await getFreeTrial()
|
const keys = [
|
||||||
if (!freeTrial.enabled) return false
|
'first_session_discount_enabled',
|
||||||
|
'first_session_discount_actual_price_idr',
|
||||||
const [tx] = await sql`
|
'first_session_discount_gimmick_price_idr',
|
||||||
SELECT id FROM customer_transactions
|
'first_session_discount_duration_minutes',
|
||||||
WHERE customer_id = ${customerId}
|
'first_session_discount_modes',
|
||||||
LIMIT 1
|
]
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT key, value FROM app_config WHERE key IN ${sql(keys)}
|
||||||
`
|
`
|
||||||
return !tx // Eligible only if no transactions at all
|
const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value?.value]))
|
||||||
}
|
|
||||||
|
|
||||||
export const getPricingForCustomer = async (customerId) => {
|
|
||||||
const tiers = await getPriceTiers()
|
|
||||||
const freeTrialEligible = await isCustomerEligibleForFreeTrial(customerId)
|
|
||||||
const freeTrial = await getFreeTrial()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tiers,
|
enabled: byKey.first_session_discount_enabled ?? DEFAULT_DISCOUNT.enabled,
|
||||||
free_trial: freeTrialEligible
|
actual_price_idr: byKey.first_session_discount_actual_price_idr ?? DEFAULT_DISCOUNT.actual_price_idr,
|
||||||
? { eligible: true, duration_minutes: freeTrial.duration_minutes }
|
gimmick_price_idr: byKey.first_session_discount_gimmick_price_idr ?? DEFAULT_DISCOUNT.gimmick_price_idr,
|
||||||
: { eligible: false },
|
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.
|
* Predicate (Phase 4):
|
||||||
* The customerId is accepted for API symmetry/future tier personalization.
|
* - 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
|
// eslint-disable-next-line no-unused-vars
|
||||||
export const getExtensionPriceTiers = async (customerId) => {
|
export const getExtensionPriceTiers = async (customerId) => {
|
||||||
const tiers = await getPriceTiers()
|
const tiers = await readChatTiers()
|
||||||
return {
|
return {
|
||||||
tiers,
|
tiers,
|
||||||
free_trial: { eligible: false },
|
first_session_discount: { eligible: false },
|
||||||
is_free_trial: 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()
|
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()
|
const sessionTimers = new Map()
|
||||||
|
|
||||||
export const startSessionTimer = (sessionId, expiresAt) => {
|
export const startSessionTimer = (sessionId, expiresAt) => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const expiresMs = new Date(expiresAt).getTime()
|
const expiresMs = new Date(expiresAt).getTime()
|
||||||
|
const threeMinMs = expiresMs - 180_000 // 3 minutes before expiry (Phase 4)
|
||||||
const warningMs = expiresMs - 60_000 // 1 minute before expiry
|
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)
|
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)
|
// Warning timer (1 min before expiry)
|
||||||
if (warningMs > now) {
|
if (warningMs > now) {
|
||||||
@@ -43,6 +59,7 @@ export const startSessionTimer = (sessionId, expiresAt) => {
|
|||||||
export const clearSessionTimer = (sessionId) => {
|
export const clearSessionTimer = (sessionId) => {
|
||||||
const timers = sessionTimers.get(sessionId)
|
const timers = sessionTimers.get(sessionId)
|
||||||
if (timers) {
|
if (timers) {
|
||||||
|
if (timers.threeMinTimeout) clearTimeout(timers.threeMinTimeout)
|
||||||
if (timers.warningTimeout) clearTimeout(timers.warningTimeout)
|
if (timers.warningTimeout) clearTimeout(timers.warningTimeout)
|
||||||
if (timers.expiryTimeout) clearTimeout(timers.expiryTimeout)
|
if (timers.expiryTimeout) clearTimeout(timers.expiryTimeout)
|
||||||
sessionTimers.delete(sessionId)
|
sessionTimers.delete(sessionId)
|
||||||
@@ -69,6 +86,21 @@ const onSessionWarning = (sessionId) => {
|
|||||||
sendToSessionParticipant(sessionId, UserType.MITRA, data)
|
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
|
// Grace period timers for auto-completing abandoned sessions
|
||||||
const closureGraceTimers = new Map()
|
const closureGraceTimers = new Map()
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const sql = getDb()
|
|||||||
export const getActiveSessionByCustomer = async (customerId) => {
|
export const getActiveSessionByCustomer = async (customerId) => {
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at,
|
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
|
m.display_name AS mitra_display_name
|
||||||
FROM chat_sessions cs
|
FROM chat_sessions cs
|
||||||
LEFT JOIN mitras m ON m.id = cs.mitra_id
|
LEFT JOIN mitras m ON m.id = cs.mitra_id
|
||||||
@@ -152,7 +152,7 @@ export const getSessionById = async (sessionId) => {
|
|||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity,
|
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.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,
|
c.display_name AS customer_display_name,
|
||||||
m.display_name AS mitra_display_name
|
m.display_name AS mitra_display_name
|
||||||
FROM chat_sessions cs
|
FROM chat_sessions cs
|
||||||
@@ -168,7 +168,7 @@ export const getSessionById = async (sessionId) => {
|
|||||||
export const getActiveSessionByCustomerWithUnread = async (customerId) => {
|
export const getActiveSessionByCustomerWithUnread = async (customerId) => {
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at,
|
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,
|
m.display_name AS mitra_display_name,
|
||||||
(SELECT COUNT(*) FROM chat_messages cm
|
(SELECT COUNT(*) FROM chat_messages cm
|
||||||
WHERE cm.session_id = cs.id AND cm.sender_type = ${UserType.MITRA}
|
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 offset = (page - 1) * limit
|
||||||
const items = await sql`
|
const items = await sql`
|
||||||
SELECT cs.id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at, cs.ended_at,
|
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,
|
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.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
|
(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 offset = (page - 1) * limit
|
||||||
const items = await sql`
|
const items = await sql`
|
||||||
SELECT cs.id, cs.customer_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at, cs.ended_at,
|
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,
|
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.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
|
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.CUSTOMER} LIMIT 1) AS customer_closure_message
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ export const resetDbHard = async () => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop and re-seed the configurable app_config rows back to their canonical defaults.
|
* 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 () => {
|
export const resetAppConfig = async () => {
|
||||||
const sql = db()
|
const sql = db()
|
||||||
@@ -61,8 +62,6 @@ export const resetAppConfig = async () => {
|
|||||||
const defaults = [
|
const defaults = [
|
||||||
['anonymity', { enabled: false }],
|
['anonymity', { enabled: false }],
|
||||||
['max_customers_per_mitra', { value: 3 }],
|
['max_customers_per_mitra', { value: 3 }],
|
||||||
['free_trial_enabled', { value: true }],
|
|
||||||
['free_trial_duration_minutes', { value: 5 }],
|
|
||||||
['extension_timeout_seconds', { value: 60 }],
|
['extension_timeout_seconds', { value: 60 }],
|
||||||
['early_end_mitra_enabled', { value: false }],
|
['early_end_mitra_enabled', { value: false }],
|
||||||
['early_end_customer_enabled', { value: false }],
|
['early_end_customer_enabled', { value: false }],
|
||||||
@@ -70,6 +69,13 @@ export const resetAppConfig = async () => {
|
|||||||
['returning_chat_confirmation_timeout_seconds', { value: 20 }],
|
['returning_chat_confirmation_timeout_seconds', { value: 20 }],
|
||||||
['extension_default_action_on_timeout', { value: 'auto_approve' }],
|
['extension_default_action_on_timeout', { value: 'auto_approve' }],
|
||||||
['pairing_blast_timeout_seconds', { value: 60 }],
|
['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) {
|
for (const [key, value] of defaults) {
|
||||||
await sql`
|
await sql`
|
||||||
|
|||||||
98
backend/test/routes/client.chat-pricing.routes.test.js
Normal file
98
backend/test/routes/client.chat-pricing.routes.test.js
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -34,7 +34,11 @@ describe('POST /api/client/payment-sessions', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await resetDb()
|
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)
|
token = customerJwt(customer.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -42,25 +46,25 @@ describe('POST /api/client/payment-sessions', () => {
|
|||||||
await app?.close()
|
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({
|
const res = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/client/payment-sessions',
|
url: '/api/client/payment-sessions',
|
||||||
headers: authHeader(token),
|
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)
|
expect(res.statusCode).toBe(201)
|
||||||
const body = res.json()
|
const body = res.json()
|
||||||
expect(body.success).toBe(true)
|
expect(body.success).toBe(true)
|
||||||
expect(body.data.status).toBe(PaymentSessionStatus.PENDING)
|
expect(body.data.status).toBe(PaymentSessionStatus.PENDING)
|
||||||
expect(body.data.duration_minutes).toBe(15)
|
expect(body.data.duration_minutes).toBe(12)
|
||||||
// Default tier for 15min from migrate.js is 30000 — but the eligibility logic
|
expect(body.data.is_first_session_discount).toBe(true)
|
||||||
// also needs `free_trial_enabled` to be true (default) AND no prior tx. Customer is
|
expect(body.data.amount).toBe(2000)
|
||||||
// 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.is_extension).toBe(false)
|
expect(body.data.is_extension).toBe(false)
|
||||||
|
expect(body.data.mode).toBe('chat')
|
||||||
|
|
||||||
// Verify persistence
|
// Verify persistence
|
||||||
const sql = db()
|
const sql = db()
|
||||||
@@ -69,35 +73,41 @@ describe('POST /api/client/payment-sessions', () => {
|
|||||||
expect(row.customer_id).toBe(customer.id)
|
expect(row.customer_id).toBe(customer.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('POST /:id/confirm transitions the row and returns 200', async () => {
|
it('non-eligible customer pays the standard tier price', async () => {
|
||||||
// Create a paid (non-trial) tier so the row has a non-zero amount and we exercise the
|
// Drop first-session-discount eligibility by inserting a completed session.
|
||||||
// confirm path with a "real" payment. Insert a transaction first so the customer is
|
|
||||||
// ineligible for the free trial.
|
|
||||||
const sql = db()
|
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`
|
await sql`
|
||||||
INSERT INTO customer_transactions (customer_id, session_id, type, amount)
|
INSERT INTO chat_sessions (customer_id, status, duration_minutes, price)
|
||||||
VALUES (${customer.id}, ${prior.id}, 'paid', 30000)
|
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({
|
const createRes = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/client/payment-sessions',
|
url: '/api/client/payment-sessions',
|
||||||
headers: authHeader(token),
|
headers: authHeader(token),
|
||||||
payload: { duration_minutes: 15 },
|
payload: { duration_minutes: 5 },
|
||||||
})
|
})
|
||||||
expect(createRes.statusCode).toBe(201)
|
expect(createRes.statusCode).toBe(201)
|
||||||
const created = createRes.json().data
|
const created = createRes.json().data
|
||||||
expect(created.status).toBe(PaymentSessionStatus.PENDING)
|
expect(created.status).toBe(PaymentSessionStatus.PENDING)
|
||||||
expect(created.is_free_trial).toBe(false)
|
expect(created.is_first_session_discount).toBe(false)
|
||||||
expect(created.amount).toBe(30000)
|
expect(created.amount).toBe(5000)
|
||||||
|
|
||||||
const confirmRes = await app.inject({
|
const confirmRes = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -112,4 +122,21 @@ describe('POST /api/client/payment-sessions', () => {
|
|||||||
expect(confirmed.status).toBe(PaymentSessionStatus.CONFIRMED)
|
expect(confirmed.status).toBe(PaymentSessionStatus.CONFIRMED)
|
||||||
expect(confirmed.confirmed_at).toBeTruthy()
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
87
backend/test/routes/shared.auth-providers.routes.test.js
Normal file
87
backend/test/routes/shared.auth-providers.routes.test.js
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -38,8 +38,9 @@ describe('payment.service', () => {
|
|||||||
expect(session.customer_id).toBe(customer.id)
|
expect(session.customer_id).toBe(customer.id)
|
||||||
expect(session.duration_minutes).toBe(15)
|
expect(session.duration_minutes).toBe(15)
|
||||||
expect(session.amount).toBe(30000)
|
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.is_extension).toBe(false)
|
||||||
|
expect(session.mode).toBe('chat')
|
||||||
expect(new Date(session.expires_at).getTime()).toBeGreaterThan(before)
|
expect(new Date(session.expires_at).getTime()).toBeGreaterThan(before)
|
||||||
|
|
||||||
// Verify it's actually persisted (not just returned from the INSERT)
|
// Verify it's actually persisted (not just returned from the INSERT)
|
||||||
|
|||||||
91
backend/test/services/session-timer.service.test.js
Normal file
91
backend/test/services/session-timer.service.test.js
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { apiClient } from '../../core/api/api-client'
|
import { apiClient } from '../../core/api/api-client'
|
||||||
import { ExtensionTimeoutAction } from '../../core/constants'
|
import { ExtensionTimeoutAction } from '../../core/constants'
|
||||||
@@ -116,6 +117,36 @@ const updateExtensionDefaultAction = async (extension_default_action_on_timeout)
|
|||||||
return res.data.data
|
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() {
|
export default function SettingsPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data, isLoading } = useQuery({ queryKey: ['config-anonymity'], queryFn: fetchAnonymityConfig })
|
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'] }),
|
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 (
|
if (
|
||||||
isLoading || maxLoading || ftLoading || etLoading || eeLoading || mpLoading || senLoading ||
|
isLoading || maxLoading || ftLoading || etLoading || eeLoading || mpLoading || senLoading ||
|
||||||
pbtLoading || pstLoading || rctLoading || edaLoading
|
pbtLoading || pstLoading || rctLoading || edaLoading ||
|
||||||
|
fsdLoading || ptLoading || shLoading
|
||||||
) return <div>Loading...</div>
|
) return <div>Loading...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -493,6 +555,193 @@ export default function SettingsPage() {
|
|||||||
</label>
|
</label>
|
||||||
{edaMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
{edaMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Phase 4: First-session discount */}
|
||||||
|
<section style={{ marginBottom: 24 }}>
|
||||||
|
<h2>Diskon Sesi Pertama (Phase 4)</h2>
|
||||||
|
<p>Diskon untuk sesi pertama customer yang sudah terverifikasi nomor HP. Menggantikan free trial Phase 3.</p>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={fsdData?.enabled ?? false}
|
||||||
|
onChange={e => fsdMutation.mutate({ enabled: e.target.checked })}
|
||||||
|
disabled={fsdMutation.isPending}
|
||||||
|
/>
|
||||||
|
Aktifkan diskon sesi pertama
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
|
<label>Harga aktual (IDR):</label>
|
||||||
|
<input
|
||||||
|
type="number" min="0"
|
||||||
|
value={fsdData?.actual_price_idr ?? 2000}
|
||||||
|
onChange={e => {
|
||||||
|
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 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
|
<label>Harga gimik / coret (IDR):</label>
|
||||||
|
<input
|
||||||
|
type="number" min="0"
|
||||||
|
value={fsdData?.gimmick_price_idr ?? 12000}
|
||||||
|
onChange={e => {
|
||||||
|
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 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
|
<label>Durasi (menit):</label>
|
||||||
|
<input
|
||||||
|
type="number" min="1"
|
||||||
|
value={fsdData?.duration_minutes ?? 12}
|
||||||
|
onChange={e => {
|
||||||
|
const v = parseInt(e.target.value, 10)
|
||||||
|
if (Number.isFinite(v) && v >= 1) fsdMutation.mutate({ duration_minutes: v })
|
||||||
|
}}
|
||||||
|
disabled={fsdMutation.isPending}
|
||||||
|
style={{ width: 80 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<span>Mode yang dapat diskon:</span>
|
||||||
|
{['chat', 'call'].map(m => (
|
||||||
|
<label key={m} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(fsdData?.modes ?? []).includes(m)}
|
||||||
|
onChange={e => {
|
||||||
|
const current = new Set(fsdData?.modes ?? [])
|
||||||
|
if (e.target.checked) current.add(m)
|
||||||
|
else current.delete(m)
|
||||||
|
fsdMutation.mutate({ modes: Array.from(current) })
|
||||||
|
}}
|
||||||
|
disabled={fsdMutation.isPending}
|
||||||
|
/>
|
||||||
|
{m}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{fsdMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Phase 4: Pricing tier groups (mock) */}
|
||||||
|
<section style={{ marginBottom: 24 }}>
|
||||||
|
<h2>Tier Harga (Mock — Phase 4)</h2>
|
||||||
|
<p>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.</p>
|
||||||
|
{['chat', 'call'].map((mode) => (
|
||||||
|
<PricingTierEditor
|
||||||
|
key={mode}
|
||||||
|
mode={mode}
|
||||||
|
tiers={ptData?.[mode] ?? []}
|
||||||
|
onSave={(tiers) => ptMutation.mutate({ mode, tiers })}
|
||||||
|
isPending={ptMutation.isPending}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{ptMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan tier — pastikan JSON valid.</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Phase 4: Support handles */}
|
||||||
|
<section style={{ marginBottom: 24 }}>
|
||||||
|
<h2>Support Handles (Tanya Admin)</h2>
|
||||||
|
<p>Deeplink WA + Telegram untuk sheet "Tanya Admin" di client_app.</p>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
|
<label style={{ width: 90 }}>WA label:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
defaultValue={shData?.wa?.label ?? 'WhatsApp'}
|
||||||
|
onBlur={e => shMutation.mutate({ wa: { label: e.target.value } })}
|
||||||
|
disabled={shMutation.isPending}
|
||||||
|
style={{ width: 240 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
|
||||||
|
<label style={{ width: 90 }}>WA deeplink:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
defaultValue={shData?.wa?.deeplink ?? ''}
|
||||||
|
onBlur={e => shMutation.mutate({ wa: { deeplink: e.target.value } })}
|
||||||
|
disabled={shMutation.isPending}
|
||||||
|
style={{ width: 360 }}
|
||||||
|
placeholder="https://wa.me/62..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
|
<label style={{ width: 90 }}>TG label:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
defaultValue={shData?.telegram?.label ?? 'Telegram'}
|
||||||
|
onBlur={e => shMutation.mutate({ telegram: { label: e.target.value } })}
|
||||||
|
disabled={shMutation.isPending}
|
||||||
|
style={{ width: 240 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<label style={{ width: 90 }}>TG deeplink:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
defaultValue={shData?.telegram?.deeplink ?? ''}
|
||||||
|
onBlur={e => shMutation.mutate({ telegram: { deeplink: e.target.value } })}
|
||||||
|
disabled={shMutation.isPending}
|
||||||
|
style={{ width: 360 }}
|
||||||
|
placeholder="https://t.me/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{shMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<h3 style={{ margin: '12px 0 4px' }}>{mode === 'chat' ? 'Chat tiers' : 'Voice call tiers'}</h3>
|
||||||
|
<textarea
|
||||||
|
value={draft}
|
||||||
|
onChange={e => setDraft(e.target.value)}
|
||||||
|
rows={10}
|
||||||
|
style={{ width: '100%', fontFamily: 'monospace', fontSize: 12 }}
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
<button onClick={handleSave} disabled={isPending} type="button">Simpan tier {mode}</button>
|
||||||
|
{error && <span style={{ color: 'red', marginLeft: 8 }}>{error}</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user