Pricing: migrate from app_config JSON to relational tables
Replaces the two `pricing_*_tiers_json` blobs and five `first_session_discount_*` keys in app_config with dedicated `pricing_tiers` and `pricing_promotions` tables plus matching `_history` audit tables. UUID PKs, UNIQUE(mode, minutes) natural-key constraint, optimistic-lock via `updated_at` token returning 409 STALE_WRITE on conflicts. Every mutation writes a history row capturing the operator (changed_by from request.auth.userId) and change_kind. CC SettingsPage replaces the JSON-textarea editors with per-row tables — add / edit / soft-delete / reactivate / reorder, plus a buffered first-session discount form with the same optimistic-lock contract. `minutes` and `mode` are read-only on edit since they form the natural key; operators soft-delete and recreate to change duration. Stage 5 fixes a latent leak: `client.payment.routes.js` had its own local `readDiscountConfig` that still read from app_config — would have silently fallen to hardcoded defaults once the legacy rows were deleted. Now reads from pricing_promotions via the shared service helper, so CC edits to the first- session discount affect actual payment pricing on the next request. Customer-facing GET /api/client/chat/pricing shape unchanged (id values are now UUIDs instead of "5"/"12"/"60" but lookups happen by (mode, minutes), so no app changes needed). 27 new backend tests, all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -679,6 +679,242 @@ const migrate = async () => {
|
||||
ADD COLUMN IF NOT EXISTS account_belongs_to UUID REFERENCES customers(id) ON DELETE SET NULL
|
||||
`
|
||||
|
||||
// --- Pricing relational migration — Stage 1 (schema + backfill only) ---
|
||||
//
|
||||
// Moves the pricing_chat_tiers_json / pricing_call_tiers_json /
|
||||
// first_session_discount_* rows from app_config into dedicated relational
|
||||
// tables. Stage 1 is schema-only: the live read paths in
|
||||
// pricing.service.js continue to read app_config until Stage 3 cuts them
|
||||
// over. The seven legacy app_config rows are NOT deleted here — that's
|
||||
// Stage 5.
|
||||
//
|
||||
// Design notes:
|
||||
// - PK is UUID (gen_random_uuid()) instead of the doc's TEXT prefix
|
||||
// scheme ("chat-60"). Backend lookups go through (mode, minutes),
|
||||
// not the id, so the id is purely internal — there is no benefit to
|
||||
// a human-readable surrogate key, and UUIDs match the convention
|
||||
// used by every other table in this schema.
|
||||
// - UNIQUE (mode, minutes) on pricing_tiers and UNIQUE (eligibility)
|
||||
// on pricing_promotions enforce the natural keys at the DB level.
|
||||
// - History tables reference the live row via tier_id / promotion_id
|
||||
// (UUID) — column rename from the doc's `id` to avoid shadowing the
|
||||
// history row's own pk.
|
||||
// - original_price_idr is shipped as schema-only — not exposed in
|
||||
// GET /api/client/pricing in this Stage. Surfacing it to clients is
|
||||
// a separate out-of-scope follow-up.
|
||||
// - sort_order: operator-controlled ordering distinct from minutes.
|
||||
// Backfill seeds it as the array index from the existing JSON so any
|
||||
// curated order is preserved.
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS pricing_tiers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
mode TEXT NOT NULL CHECK (mode IN ('chat', 'call')),
|
||||
minutes INTEGER NOT NULL CHECK (minutes > 0),
|
||||
price_idr INTEGER NOT NULL CHECK (price_idr >= 0),
|
||||
original_price_idr INTEGER CHECK (original_price_idr IS NULL OR original_price_idr >= price_idr),
|
||||
tag TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (mode, minutes)
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_pricing_tiers_mode_active_sort
|
||||
ON pricing_tiers (mode, is_active, sort_order)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS pricing_promotions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
eligibility TEXT NOT NULL UNIQUE
|
||||
CHECK (eligibility IN ('first_session')),
|
||||
actual_price_idr INTEGER NOT NULL CHECK (actual_price_idr >= 0),
|
||||
gimmick_price_idr INTEGER CHECK (gimmick_price_idr IS NULL OR gimmick_price_idr >= actual_price_idr),
|
||||
duration_minutes INTEGER NOT NULL CHECK (duration_minutes > 0),
|
||||
modes TEXT[] NOT NULL CHECK (
|
||||
array_length(modes, 1) >= 1
|
||||
AND modes <@ ARRAY['chat', 'call']::TEXT[]
|
||||
),
|
||||
starts_at TIMESTAMPTZ,
|
||||
ends_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS pricing_tiers_history (
|
||||
history_id BIGSERIAL PRIMARY KEY,
|
||||
tier_id UUID NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
minutes INTEGER NOT NULL,
|
||||
price_idr INTEGER NOT NULL,
|
||||
original_price_idr INTEGER,
|
||||
tag TEXT,
|
||||
sort_order INTEGER NOT NULL,
|
||||
is_active BOOLEAN NOT NULL,
|
||||
changed_by UUID,
|
||||
change_kind TEXT NOT NULL CHECK (change_kind IN ('create', 'update', 'delete')),
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_pricing_tiers_history_tier_time
|
||||
ON pricing_tiers_history (tier_id, changed_at DESC)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS pricing_promotions_history (
|
||||
history_id BIGSERIAL PRIMARY KEY,
|
||||
promotion_id UUID NOT NULL,
|
||||
enabled BOOLEAN NOT NULL,
|
||||
eligibility TEXT NOT NULL,
|
||||
actual_price_idr INTEGER NOT NULL,
|
||||
gimmick_price_idr INTEGER,
|
||||
duration_minutes INTEGER NOT NULL,
|
||||
modes TEXT[] NOT NULL,
|
||||
starts_at TIMESTAMPTZ,
|
||||
ends_at TIMESTAMPTZ,
|
||||
changed_by UUID,
|
||||
change_kind TEXT NOT NULL CHECK (change_kind IN ('create', 'update', 'delete')),
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_pricing_promotions_history_promo_time
|
||||
ON pricing_promotions_history (promotion_id, changed_at DESC)
|
||||
`
|
||||
|
||||
// --- Backfill: pricing_tiers ---
|
||||
//
|
||||
// Only seeds when the table is empty so re-runs don't clobber operator
|
||||
// edits or insert duplicates. The unique(mode, minutes) constraint is a
|
||||
// belt-and-suspenders backstop in case a half-finished run is retried.
|
||||
//
|
||||
// Source preference order per mode:
|
||||
// 1. app_config.pricing_{chat,call}_tiers_json (if the row exists)
|
||||
// 2. Hardcoded defaults that match DEFAULT_{CHAT,CALL}_TIERS in
|
||||
// pricing.service.js. We duplicate them here rather than importing
|
||||
// because migrate.js is a standalone script.
|
||||
|
||||
const DEFAULT_CHAT_TIERS_BACKFILL = [
|
||||
{ minutes: 5, price_idr: 5000, tag: null },
|
||||
{ minutes: 12, price_idr: 12000, tag: 'paling pas' },
|
||||
{ minutes: 30, price_idr: 25000, tag: 'hemat' },
|
||||
{ minutes: 60, price_idr: 45000, tag: null },
|
||||
{ minutes: 120, price_idr: 80000, tag: 'best deal' },
|
||||
]
|
||||
const DEFAULT_CALL_TIERS_BACKFILL = [
|
||||
{ minutes: 10, price_idr: 9000, tag: null },
|
||||
{ minutes: 20, price_idr: 17000, tag: 'paling pas' },
|
||||
{ minutes: 45, price_idr: 35000, tag: null },
|
||||
{ minutes: 60, price_idr: 45000, tag: 'hemat' },
|
||||
]
|
||||
|
||||
const [{ n: tierCount }] = await sql`SELECT COUNT(*)::int AS n FROM pricing_tiers`
|
||||
if (tierCount === 0) {
|
||||
const [chatRow] = await sql`SELECT value FROM app_config WHERE key = 'pricing_chat_tiers_json'`
|
||||
const [callRow] = await sql`SELECT value FROM app_config WHERE key = 'pricing_call_tiers_json'`
|
||||
|
||||
const chatTiers = Array.isArray(chatRow?.value?.tiers) ? chatRow.value.tiers : DEFAULT_CHAT_TIERS_BACKFILL
|
||||
const callTiers = Array.isArray(callRow?.value?.tiers) ? callRow.value.tiers : DEFAULT_CALL_TIERS_BACKFILL
|
||||
|
||||
for (const [mode, tiers] of [['chat', chatTiers], ['call', callTiers]]) {
|
||||
let order = 0
|
||||
for (const t of tiers) {
|
||||
await sql`
|
||||
INSERT INTO pricing_tiers (mode, minutes, price_idr, tag, sort_order, is_active)
|
||||
VALUES (
|
||||
${mode},
|
||||
${t.minutes},
|
||||
${t.price_idr},
|
||||
${t.tag ?? null},
|
||||
${order++},
|
||||
true
|
||||
)
|
||||
ON CONFLICT (mode, minutes) DO NOTHING
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Backfill: pricing_promotions (single 'first_session' row) ---
|
||||
//
|
||||
// Defaults below match DEFAULT_DISCOUNT in pricing.service.js. The five
|
||||
// legacy app_config keys override these if present.
|
||||
|
||||
const [{ n: promoCount }] = await sql`
|
||||
SELECT COUNT(*)::int AS n FROM pricing_promotions WHERE eligibility = 'first_session'
|
||||
`
|
||||
if (promoCount === 0) {
|
||||
const keys = [
|
||||
'first_session_discount_enabled',
|
||||
'first_session_discount_actual_price_idr',
|
||||
'first_session_discount_gimmick_price_idr',
|
||||
'first_session_discount_duration_minutes',
|
||||
'first_session_discount_modes',
|
||||
]
|
||||
const rows = await sql`SELECT key, value FROM app_config WHERE key IN ${sql(keys)}`
|
||||
const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value?.value]))
|
||||
|
||||
const enabled = byKey.first_session_discount_enabled ?? true
|
||||
const actual = byKey.first_session_discount_actual_price_idr ?? 2000
|
||||
const gimmick = byKey.first_session_discount_gimmick_price_idr ?? 12000
|
||||
const duration = byKey.first_session_discount_duration_minutes ?? 12
|
||||
const modes = Array.isArray(byKey.first_session_discount_modes)
|
||||
? byKey.first_session_discount_modes
|
||||
: ['chat']
|
||||
|
||||
await sql`
|
||||
INSERT INTO pricing_promotions (
|
||||
enabled, eligibility, actual_price_idr, gimmick_price_idr, duration_minutes, modes
|
||||
)
|
||||
VALUES (
|
||||
${enabled},
|
||||
'first_session',
|
||||
${actual},
|
||||
${gimmick},
|
||||
${duration},
|
||||
${modes}
|
||||
)
|
||||
ON CONFLICT (eligibility) DO NOTHING
|
||||
`
|
||||
}
|
||||
|
||||
// --- Pricing relational migration — Stage 5 cleanup ---
|
||||
//
|
||||
// The seven legacy `app_config` rows below were the JSON-on-app_config
|
||||
// source of truth for pricing tiers and the first-session discount, copied
|
||||
// into `pricing_tiers` / `pricing_promotions` by the Stage 1 backfill above.
|
||||
// Stage 3 cut every read path over to the relational tables; this delete
|
||||
// removes the now-orphaned rows so operator edits in CC can't get out of
|
||||
// sync with the live source.
|
||||
//
|
||||
// Idempotent: a fresh dev DB just deletes zero rows. A previously-migrated
|
||||
// DB on this revision is a no-op. The seed of these keys in the Phase 4
|
||||
// app_config INSERT block above (~line 627) uses ON CONFLICT (key) DO
|
||||
// NOTHING — so even if the seed runs *after* this delete during a future
|
||||
// refactor, we don't accidentally resurrect them on the next pass.
|
||||
await sql`
|
||||
DELETE 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',
|
||||
'pricing_chat_tiers_json',
|
||||
'pricing_call_tiers_json'
|
||||
)
|
||||
`
|
||||
|
||||
console.log('Migration complete.')
|
||||
await sql.end()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { authenticate, requirePermission } from '../../plugins/auth.js'
|
||||
import { getCcUserById } from '../../services/cc-user.service.js'
|
||||
import { UserType, ExtensionTimeoutAction } from '../../constants.js'
|
||||
import { UserType, ExtensionTimeoutAction, SessionMode } from '../../constants.js'
|
||||
import { publish } from '../../plugins/valkey.js'
|
||||
import { getDb } from '../../db/client.js'
|
||||
import {
|
||||
getAnonymityConfig, setAnonymityConfig,
|
||||
getMaxCustomersPerMitra, setMaxCustomersPerMitra,
|
||||
@@ -14,11 +15,44 @@ import {
|
||||
getReturningChatConfirmationTimeoutSeconds, setReturningChatConfirmationTimeoutSeconds,
|
||||
getExtensionDefaultActionOnTimeout, setExtensionDefaultActionOnTimeout,
|
||||
getPairingBlastTimeoutSeconds, setPairingBlastTimeoutSeconds,
|
||||
getFirstSessionDiscountConfig, setFirstSessionDiscountConfig,
|
||||
getSupportHandles, setSupportHandles,
|
||||
getPricingTierGroups, setPricingTierGroup,
|
||||
} from '../../services/config.service.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
// RFC 4122 UUID format (any variant). Loose check is fine — Postgres will reject
|
||||
// malformed UUIDs at query time, but rejecting early gives a clean 422 instead.
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
const validation = (message, field) => ({
|
||||
success: false,
|
||||
error: { code: 'VALIDATION', message, ...(field ? { field } : {}) },
|
||||
})
|
||||
|
||||
const staleWrite = (serverUpdatedAt) => ({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'STALE_WRITE',
|
||||
message: 'Pricing tier was updated by someone else. Reload and try again.',
|
||||
server_updated_at: serverUpdatedAt,
|
||||
},
|
||||
})
|
||||
|
||||
const notFound = (message = 'Not found') => ({
|
||||
success: false,
|
||||
error: { code: 'NOT_FOUND', message },
|
||||
})
|
||||
|
||||
// `updated_at` from a GET response may arrive as an ISO-8601 string or a JS Date.
|
||||
// Compare on the underlying millisecond value to dodge millisecond-precision drift
|
||||
// from string round-tripping through PG's timestamptz <-> postgres.js Date.
|
||||
const updatedAtMatches = (a, b) => {
|
||||
if (!a || !b) return false
|
||||
const aMs = a instanceof Date ? a.getTime() : new Date(a).getTime()
|
||||
const bMs = b instanceof Date ? b.getTime() : new Date(b).getTime()
|
||||
return Number.isFinite(aMs) && Number.isFinite(bMs) && aMs === bMs
|
||||
}
|
||||
|
||||
const attachCcUser = async (request, reply) => {
|
||||
if (request.auth?.userType !== UserType.CC_USER) {
|
||||
return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } })
|
||||
@@ -288,21 +322,320 @@ export const internalConfigRoutes = async (app) => {
|
||||
return reply.send({ success: true, data: config })
|
||||
})
|
||||
|
||||
// --- Phase 4: First-session discount ---
|
||||
// --- Phase 4 / Stage 3 (relational pricing): per-row CRUD with optimistic-lock ---
|
||||
//
|
||||
// Pricing tiers and the first-session discount promotion now live in dedicated
|
||||
// relational tables (pricing_tiers / pricing_promotions). The old full-replace
|
||||
// PATCH /:mode handler is gone — every write here goes through per-row CRUD with
|
||||
// an updated_at optimistic-lock token. History rows are written in the same
|
||||
// transaction as the live-row mutation so audit can't drift from reality.
|
||||
//
|
||||
// Channel names on publishConfigInvalidate are preserved (pricing_chat_tiers_json /
|
||||
// pricing_call_tiers_json / first_session_discount) so any in-process cache
|
||||
// subscribers keep working without rewires.
|
||||
|
||||
/**
|
||||
* Snapshot a tier row into pricing_tiers_history. Called inside the same
|
||||
* transaction as the live-row write so audit can't drift from reality.
|
||||
*/
|
||||
const writeTierHistory = async (tx, row, { changedBy, changeKind }) => {
|
||||
await tx`
|
||||
INSERT INTO pricing_tiers_history (
|
||||
tier_id, mode, minutes, price_idr, original_price_idr, tag,
|
||||
sort_order, is_active, changed_by, change_kind
|
||||
) VALUES (
|
||||
${row.id}, ${row.mode}, ${row.minutes}, ${row.price_idr},
|
||||
${row.original_price_idr ?? null}, ${row.tag ?? null},
|
||||
${row.sort_order}, ${row.is_active},
|
||||
${changedBy ?? null}, ${changeKind}
|
||||
)
|
||||
`
|
||||
}
|
||||
|
||||
const writePromotionHistory = async (tx, row, { changedBy, changeKind }) => {
|
||||
await tx`
|
||||
INSERT INTO pricing_promotions_history (
|
||||
promotion_id, enabled, eligibility, actual_price_idr, gimmick_price_idr,
|
||||
duration_minutes, modes, starts_at, ends_at, changed_by, change_kind
|
||||
) VALUES (
|
||||
${row.id}, ${row.enabled}, ${row.eligibility}, ${row.actual_price_idr},
|
||||
${row.gimmick_price_idr ?? null}, ${row.duration_minutes}, ${row.modes},
|
||||
${row.starts_at ?? null}, ${row.ends_at ?? null},
|
||||
${changedBy ?? null}, ${changeKind}
|
||||
)
|
||||
`
|
||||
}
|
||||
|
||||
// --- Pricing tiers ---
|
||||
|
||||
app.get('/pricing-tiers', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||
}, async (_req, reply) => {
|
||||
const rows = await sql`
|
||||
SELECT id, mode, minutes, price_idr, original_price_idr, tag,
|
||||
sort_order, is_active, created_at, updated_at
|
||||
FROM pricing_tiers
|
||||
ORDER BY mode ASC, sort_order ASC, minutes ASC
|
||||
`
|
||||
const data = { chat: [], call: [] }
|
||||
for (const r of rows) {
|
||||
if (r.mode === SessionMode.CHAT) data.chat.push(r)
|
||||
else if (r.mode === SessionMode.CALL) data.call.push(r)
|
||||
}
|
||||
return reply.send({ success: true, data })
|
||||
})
|
||||
|
||||
app.post('/pricing-tiers', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const body = request.body ?? {}
|
||||
const { mode, minutes, price_idr, original_price_idr, tag, sort_order } = body
|
||||
|
||||
if (mode !== SessionMode.CHAT && mode !== SessionMode.CALL) {
|
||||
return reply.code(422).send(validation('mode must be "chat" or "call"', 'mode'))
|
||||
}
|
||||
if (typeof minutes !== 'number' || !Number.isInteger(minutes) || minutes <= 0) {
|
||||
return reply.code(422).send(validation('minutes must be a positive integer', 'minutes'))
|
||||
}
|
||||
if (typeof price_idr !== 'number' || !Number.isInteger(price_idr) || price_idr < 0) {
|
||||
return reply.code(422).send(validation('price_idr must be a non-negative integer', 'price_idr'))
|
||||
}
|
||||
if (original_price_idr !== undefined && original_price_idr !== null) {
|
||||
if (typeof original_price_idr !== 'number' || !Number.isInteger(original_price_idr) || original_price_idr < price_idr) {
|
||||
return reply.code(422).send(validation('original_price_idr must be an integer >= price_idr', 'original_price_idr'))
|
||||
}
|
||||
}
|
||||
if (tag !== undefined && tag !== null && typeof tag !== 'string') {
|
||||
return reply.code(422).send(validation('tag must be a string or null', 'tag'))
|
||||
}
|
||||
if (sort_order !== undefined && (typeof sort_order !== 'number' || !Number.isInteger(sort_order))) {
|
||||
return reply.code(422).send(validation('sort_order must be an integer', 'sort_order'))
|
||||
}
|
||||
|
||||
// Pre-check uniqueness so we can return a friendly 422 instead of a 23505 leak.
|
||||
const [dup] = await sql`
|
||||
SELECT id FROM pricing_tiers WHERE mode = ${mode} AND minutes = ${minutes}
|
||||
`
|
||||
if (dup) {
|
||||
return reply.code(422).send(validation(`A tier already exists for ${mode}/${minutes}min`, 'minutes'))
|
||||
}
|
||||
|
||||
let inserted
|
||||
try {
|
||||
inserted = await sql.begin(async (tx) => {
|
||||
const [row] = await tx`
|
||||
INSERT INTO pricing_tiers (mode, minutes, price_idr, original_price_idr, tag, sort_order)
|
||||
VALUES (
|
||||
${mode}, ${minutes}, ${price_idr},
|
||||
${original_price_idr ?? null},
|
||||
${tag ?? null},
|
||||
${sort_order ?? 0}
|
||||
)
|
||||
RETURNING id, mode, minutes, price_idr, original_price_idr, tag,
|
||||
sort_order, is_active, created_at, updated_at
|
||||
`
|
||||
await writeTierHistory(tx, row, { changedBy: request.auth.userId, changeKind: 'create' })
|
||||
return row
|
||||
})
|
||||
} catch (err) {
|
||||
// 23505 = unique_violation (the (mode, minutes) constraint). Race with another writer.
|
||||
if (err.code === '23505') {
|
||||
return reply.code(422).send(validation(`A tier already exists for ${mode}/${minutes}min`, 'minutes'))
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
await publishConfigInvalidate(`pricing_${mode}_tiers_json`)
|
||||
return reply.code(201).send({ success: true, data: inserted })
|
||||
})
|
||||
|
||||
app.patch('/pricing-tiers/:id', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params
|
||||
if (!UUID_RE.test(id)) {
|
||||
return reply.code(422).send(validation('id must be a UUID', 'id'))
|
||||
}
|
||||
|
||||
const body = request.body ?? {}
|
||||
const { updated_at: clientUpdatedAt, price_idr, original_price_idr, tag, sort_order, is_active } = body
|
||||
|
||||
if (!clientUpdatedAt) {
|
||||
return reply.code(422).send(validation('updated_at is required (optimistic-lock token)', 'updated_at'))
|
||||
}
|
||||
|
||||
// Build patch object — only include fields the caller actually sent.
|
||||
const patch = {}
|
||||
if (price_idr !== undefined) {
|
||||
if (typeof price_idr !== 'number' || !Number.isInteger(price_idr) || price_idr < 0) {
|
||||
return reply.code(422).send(validation('price_idr must be a non-negative integer', 'price_idr'))
|
||||
}
|
||||
patch.price_idr = price_idr
|
||||
}
|
||||
if (original_price_idr !== undefined) {
|
||||
if (original_price_idr !== null && (typeof original_price_idr !== 'number' || !Number.isInteger(original_price_idr) || original_price_idr < 0)) {
|
||||
return reply.code(422).send(validation('original_price_idr must be a non-negative integer or null', 'original_price_idr'))
|
||||
}
|
||||
patch.original_price_idr = original_price_idr
|
||||
}
|
||||
if (tag !== undefined) {
|
||||
if (tag !== null && typeof tag !== 'string') {
|
||||
return reply.code(422).send(validation('tag must be a string or null', 'tag'))
|
||||
}
|
||||
patch.tag = tag
|
||||
}
|
||||
if (sort_order !== undefined) {
|
||||
if (typeof sort_order !== 'number' || !Number.isInteger(sort_order)) {
|
||||
return reply.code(422).send(validation('sort_order must be an integer', 'sort_order'))
|
||||
}
|
||||
patch.sort_order = sort_order
|
||||
}
|
||||
if (is_active !== undefined) {
|
||||
if (typeof is_active !== 'boolean') {
|
||||
return reply.code(422).send(validation('is_active must be a boolean', 'is_active'))
|
||||
}
|
||||
patch.is_active = is_active
|
||||
}
|
||||
|
||||
const [existing] = await sql`
|
||||
SELECT id, mode, minutes, price_idr, original_price_idr, tag,
|
||||
sort_order, is_active, updated_at
|
||||
FROM pricing_tiers WHERE id = ${id}
|
||||
`
|
||||
if (!existing) {
|
||||
return reply.code(404).send(notFound('Pricing tier not found'))
|
||||
}
|
||||
if (!updatedAtMatches(clientUpdatedAt, existing.updated_at)) {
|
||||
return reply.code(409).send(staleWrite(existing.updated_at))
|
||||
}
|
||||
|
||||
// Cross-field check: post-patch original_price_idr >= post-patch price_idr.
|
||||
const nextPrice = patch.price_idr ?? existing.price_idr
|
||||
const nextOrig = patch.original_price_idr === undefined ? existing.original_price_idr : patch.original_price_idr
|
||||
if (nextOrig !== null && nextOrig < nextPrice) {
|
||||
return reply.code(422).send(validation('original_price_idr must be >= price_idr', 'original_price_idr'))
|
||||
}
|
||||
|
||||
const updated = await sql.begin(async (tx) => {
|
||||
// Re-check inside the transaction with FOR UPDATE so a concurrent writer's
|
||||
// commit between our pre-check and our UPDATE bumps the timestamp and we
|
||||
// catch it. Without this, two stale-with-same-token PATCHes could both win.
|
||||
const [locked] = await tx`
|
||||
SELECT id, updated_at FROM pricing_tiers WHERE id = ${id} FOR UPDATE
|
||||
`
|
||||
if (!locked) return { _notFound: true }
|
||||
if (!updatedAtMatches(clientUpdatedAt, locked.updated_at)) {
|
||||
return { _stale: locked.updated_at }
|
||||
}
|
||||
|
||||
const [row] = await tx`
|
||||
UPDATE pricing_tiers
|
||||
SET price_idr = ${patch.price_idr ?? existing.price_idr},
|
||||
original_price_idr = ${patch.original_price_idr === undefined ? existing.original_price_idr : patch.original_price_idr},
|
||||
tag = ${patch.tag === undefined ? existing.tag : patch.tag},
|
||||
sort_order = ${patch.sort_order ?? existing.sort_order},
|
||||
is_active = ${patch.is_active ?? existing.is_active},
|
||||
updated_at = NOW()
|
||||
WHERE id = ${id}
|
||||
RETURNING id, mode, minutes, price_idr, original_price_idr, tag,
|
||||
sort_order, is_active, created_at, updated_at
|
||||
`
|
||||
await writeTierHistory(tx, row, { changedBy: request.auth.userId, changeKind: 'update' })
|
||||
return row
|
||||
})
|
||||
|
||||
if (updated._notFound) return reply.code(404).send(notFound('Pricing tier not found'))
|
||||
if (updated._stale) return reply.code(409).send(staleWrite(updated._stale))
|
||||
|
||||
await publishConfigInvalidate(`pricing_${updated.mode}_tiers_json`)
|
||||
return reply.send({ success: true, data: updated })
|
||||
})
|
||||
|
||||
app.delete('/pricing-tiers/:id', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params
|
||||
if (!UUID_RE.test(id)) {
|
||||
return reply.code(422).send(validation('id must be a UUID', 'id'))
|
||||
}
|
||||
|
||||
// updated_at is the optimistic-lock token even for DELETE — same contract as PATCH.
|
||||
const clientUpdatedAt = (request.body ?? {}).updated_at
|
||||
if (!clientUpdatedAt) {
|
||||
return reply.code(422).send(validation('updated_at is required (optimistic-lock token)', 'updated_at'))
|
||||
}
|
||||
|
||||
const [existing] = await sql`
|
||||
SELECT id, mode, updated_at FROM pricing_tiers WHERE id = ${id}
|
||||
`
|
||||
if (!existing) return reply.code(404).send(notFound('Pricing tier not found'))
|
||||
if (!updatedAtMatches(clientUpdatedAt, existing.updated_at)) {
|
||||
return reply.code(409).send(staleWrite(existing.updated_at))
|
||||
}
|
||||
|
||||
const result = await sql.begin(async (tx) => {
|
||||
const [locked] = await tx`
|
||||
SELECT id, updated_at FROM pricing_tiers WHERE id = ${id} FOR UPDATE
|
||||
`
|
||||
if (!locked) return { _notFound: true }
|
||||
if (!updatedAtMatches(clientUpdatedAt, locked.updated_at)) {
|
||||
return { _stale: locked.updated_at }
|
||||
}
|
||||
|
||||
const [row] = await tx`
|
||||
UPDATE pricing_tiers
|
||||
SET is_active = false, updated_at = NOW()
|
||||
WHERE id = ${id}
|
||||
RETURNING id, mode, minutes, price_idr, original_price_idr, tag,
|
||||
sort_order, is_active, created_at, updated_at
|
||||
`
|
||||
await writeTierHistory(tx, row, { changedBy: request.auth.userId, changeKind: 'delete' })
|
||||
return row
|
||||
})
|
||||
|
||||
if (result._notFound) return reply.code(404).send(notFound('Pricing tier not found'))
|
||||
if (result._stale) return reply.code(409).send(staleWrite(result._stale))
|
||||
|
||||
await publishConfigInvalidate(`pricing_${result.mode}_tiers_json`)
|
||||
return reply.send({ success: true, data: result })
|
||||
})
|
||||
|
||||
// --- First-session discount (single promotion row, eligibility='first_session') ---
|
||||
|
||||
app.get('/first-session-discount', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||
}, async (_req, reply) => {
|
||||
return reply.send({ success: true, data: await getFirstSessionDiscountConfig() })
|
||||
const [row] = await sql`
|
||||
SELECT id, enabled, eligibility, actual_price_idr, gimmick_price_idr,
|
||||
duration_minutes, modes, starts_at, ends_at, created_at, updated_at
|
||||
FROM pricing_promotions
|
||||
WHERE eligibility = 'first_session'
|
||||
`
|
||||
if (!row) return reply.code(404).send(notFound('First-session discount not configured'))
|
||||
return reply.send({ success: true, data: row })
|
||||
})
|
||||
|
||||
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 body = request.body ?? {}
|
||||
const {
|
||||
updated_at: clientUpdatedAt,
|
||||
enabled,
|
||||
actual_price_idr,
|
||||
gimmick_price_idr,
|
||||
duration_minutes,
|
||||
modes,
|
||||
} = body
|
||||
|
||||
if (!clientUpdatedAt) {
|
||||
return reply.code(422).send(validation('updated_at is required (optimistic-lock token)', 'updated_at'))
|
||||
}
|
||||
|
||||
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' } })
|
||||
return reply.code(422).send(validation('enabled must be a boolean', 'enabled'))
|
||||
}
|
||||
patch.enabled = enabled
|
||||
}
|
||||
@@ -312,53 +645,77 @@ export const internalConfigRoutes = async (app) => {
|
||||
['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` } })
|
||||
if (value === null && field === 'gimmick_price_idr') {
|
||||
patch[field] = null
|
||||
continue
|
||||
}
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||
return reply.code(422).send(validation(`${field} must be a non-negative number`, field))
|
||||
}
|
||||
if (field === 'duration_minutes' && value <= 0) {
|
||||
return reply.code(422).send(validation('duration_minutes must be > 0', field))
|
||||
}
|
||||
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"' } })
|
||||
if (
|
||||
!Array.isArray(modes)
|
||||
|| modes.length === 0
|
||||
|| modes.some((m) => m !== SessionMode.CHAT && m !== SessionMode.CALL)
|
||||
) {
|
||||
return reply.code(422).send(validation('modes must be a non-empty array of "chat" | "call"', 'modes'))
|
||||
}
|
||||
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() })
|
||||
})
|
||||
const [existing] = await sql`
|
||||
SELECT id, enabled, eligibility, actual_price_idr, gimmick_price_idr,
|
||||
duration_minutes, modes, starts_at, ends_at, updated_at
|
||||
FROM pricing_promotions WHERE eligibility = 'first_session'
|
||||
`
|
||||
if (!existing) return reply.code(404).send(notFound('First-session discount not configured'))
|
||||
if (!updatedAtMatches(clientUpdatedAt, existing.updated_at)) {
|
||||
return reply.code(409).send(staleWrite(existing.updated_at))
|
||||
}
|
||||
|
||||
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' } })
|
||||
// Cross-field: gimmick_price_idr (if set) must be >= actual_price_idr.
|
||||
const nextActual = patch.actual_price_idr ?? existing.actual_price_idr
|
||||
const nextGimmick = patch.gimmick_price_idr === undefined ? existing.gimmick_price_idr : patch.gimmick_price_idr
|
||||
if (nextGimmick !== null && nextGimmick < nextActual) {
|
||||
return reply.code(422).send(validation('gimmick_price_idr must be >= actual_price_idr', 'gimmick_price_idr'))
|
||||
}
|
||||
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 updated = await sql.begin(async (tx) => {
|
||||
const [locked] = await tx`
|
||||
SELECT id, updated_at FROM pricing_promotions WHERE id = ${existing.id} FOR UPDATE
|
||||
`
|
||||
if (!locked) return { _notFound: true }
|
||||
if (!updatedAtMatches(clientUpdatedAt, locked.updated_at)) {
|
||||
return { _stale: locked.updated_at }
|
||||
}
|
||||
}
|
||||
const config = await setPricingTierGroup(mode, tiers)
|
||||
await publishConfigInvalidate(`pricing_${mode}_tiers_json`)
|
||||
return reply.send({ success: true, data: config })
|
||||
|
||||
const [row] = await tx`
|
||||
UPDATE pricing_promotions
|
||||
SET enabled = ${patch.enabled ?? existing.enabled},
|
||||
actual_price_idr = ${patch.actual_price_idr ?? existing.actual_price_idr},
|
||||
gimmick_price_idr = ${patch.gimmick_price_idr === undefined ? existing.gimmick_price_idr : patch.gimmick_price_idr},
|
||||
duration_minutes = ${patch.duration_minutes ?? existing.duration_minutes},
|
||||
modes = ${patch.modes ?? existing.modes},
|
||||
updated_at = NOW()
|
||||
WHERE id = ${existing.id}
|
||||
RETURNING id, enabled, eligibility, actual_price_idr, gimmick_price_idr,
|
||||
duration_minutes, modes, starts_at, ends_at, created_at, updated_at
|
||||
`
|
||||
await writePromotionHistory(tx, row, { changedBy: request.auth.userId, changeKind: 'update' })
|
||||
return row
|
||||
})
|
||||
|
||||
if (updated._notFound) return reply.code(404).send(notFound('First-session discount not configured'))
|
||||
if (updated._stale) return reply.code(409).send(staleWrite(updated._stale))
|
||||
|
||||
await publishConfigInvalidate('first_session_discount')
|
||||
return reply.send({ success: true, data: updated })
|
||||
})
|
||||
|
||||
// --- Phase 4: Support handles ---
|
||||
|
||||
@@ -11,12 +11,10 @@ import {
|
||||
isCustomerEligibleForFirstSessionDiscount,
|
||||
isValidTier,
|
||||
findTier,
|
||||
readFirstSessionDiscountConfig,
|
||||
} from '../../services/pricing.service.js'
|
||||
import { getDb } from '../../db/client.js'
|
||||
import { UserType, SessionMode } from '../../constants.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
const resolveCustomer = async (request, reply) => {
|
||||
if (request.auth?.userType !== UserType.CUSTOMER) {
|
||||
return reply.code(403).send({
|
||||
@@ -34,25 +32,6 @@ const resolveCustomer = async (request, reply) => {
|
||||
request.customer = customer
|
||||
}
|
||||
|
||||
const readDiscountConfig = async () => {
|
||||
const rows = await sql`
|
||||
SELECT key, value FROM app_config
|
||||
WHERE key IN (
|
||||
'first_session_discount_enabled',
|
||||
'first_session_discount_actual_price_idr',
|
||||
'first_session_discount_duration_minutes',
|
||||
'first_session_discount_modes'
|
||||
)
|
||||
`
|
||||
const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value?.value]))
|
||||
return {
|
||||
enabled: byKey.first_session_discount_enabled ?? true,
|
||||
actual_price_idr: byKey.first_session_discount_actual_price_idr ?? 2000,
|
||||
duration_minutes: byKey.first_session_discount_duration_minutes ?? 12,
|
||||
modes: byKey.first_session_discount_modes ?? ['chat'],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment session lifecycle (mocked — no Xendit yet).
|
||||
*
|
||||
@@ -92,7 +71,7 @@ export const clientPaymentRoutes = async (app) => {
|
||||
if (!is_extension) {
|
||||
const eligible = await isCustomerEligibleForFirstSessionDiscount(request.customer.id)
|
||||
if (eligible) {
|
||||
const discount = await readDiscountConfig()
|
||||
const discount = await readFirstSessionDiscountConfig()
|
||||
// Discount is mode-gated. With default config (modes: ['chat']) call-mode never
|
||||
// gets the discount even if the user is eligible.
|
||||
if (
|
||||
|
||||
@@ -35,63 +35,47 @@ export const setMaxCustomersPerMitra = async (value) => {
|
||||
return { max_customers_per_mitra: value }
|
||||
}
|
||||
|
||||
// --- Phase 4: First-session discount (replaces Phase 3 free-trial config) ---
|
||||
// --- Phase 4: First-session discount config (back-compat shim) ---
|
||||
//
|
||||
// The canonical source of truth for the first-session discount lives in the
|
||||
// `pricing_promotions` table (eligibility = 'first_session'). The CC settings
|
||||
// page still calls `/internal/config/free-trial`, which exposes a slim
|
||||
// {enabled, duration_minutes} view — kept as a back-compat shim until the CC
|
||||
// UI is migrated to the richer /internal/config/first-session-discount handler.
|
||||
// Reads and writes go directly against `pricing_promotions` so operator edits
|
||||
// stay in sync with the customer-facing pricing payload.
|
||||
//
|
||||
// The legacy `first_session_discount_*` keys in `app_config` were retired in
|
||||
// Stage 5 (deleted by migrate.js) — do NOT reintroduce them.
|
||||
|
||||
export const getFirstSessionDiscountConfig = async () => {
|
||||
const rows = await sql`
|
||||
SELECT key, value FROM app_config
|
||||
WHERE key IN (
|
||||
'first_session_discount_enabled',
|
||||
'first_session_discount_actual_price_idr',
|
||||
'first_session_discount_gimmick_price_idr',
|
||||
'first_session_discount_duration_minutes',
|
||||
'first_session_discount_modes'
|
||||
)
|
||||
`
|
||||
const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value?.value]))
|
||||
return {
|
||||
enabled: byKey.first_session_discount_enabled ?? true,
|
||||
actual_price_idr: byKey.first_session_discount_actual_price_idr ?? 2000,
|
||||
gimmick_price_idr: byKey.first_session_discount_gimmick_price_idr ?? 12000,
|
||||
duration_minutes: byKey.first_session_discount_duration_minutes ?? 12,
|
||||
modes: byKey.first_session_discount_modes ?? ['chat'],
|
||||
}
|
||||
}
|
||||
|
||||
export const setFirstSessionDiscountConfig = async (patch) => {
|
||||
const map = {
|
||||
enabled: 'first_session_discount_enabled',
|
||||
actual_price_idr: 'first_session_discount_actual_price_idr',
|
||||
gimmick_price_idr: 'first_session_discount_gimmick_price_idr',
|
||||
duration_minutes: 'first_session_discount_duration_minutes',
|
||||
modes: 'first_session_discount_modes',
|
||||
}
|
||||
for (const [field, key] of Object.entries(map)) {
|
||||
if (patch[field] === undefined) continue
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value, updated_at)
|
||||
VALUES (${key}, ${sql.json({ value: patch[field] })}, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`
|
||||
}
|
||||
return getFirstSessionDiscountConfig()
|
||||
}
|
||||
|
||||
// Back-compat shim — CC settings page still calls /internal/config/free-trial.
|
||||
// Phase 4 routes will be added; until the CC UI is migrated this maps to the new keys.
|
||||
export const getFreeTrialConfig = async () => {
|
||||
const cfg = await getFirstSessionDiscountConfig()
|
||||
const [row] = await sql`
|
||||
SELECT enabled, duration_minutes FROM pricing_promotions
|
||||
WHERE eligibility = 'first_session'
|
||||
`
|
||||
return {
|
||||
enabled: cfg.enabled,
|
||||
duration_minutes: cfg.duration_minutes,
|
||||
enabled: row?.enabled ?? true,
|
||||
duration_minutes: row?.duration_minutes ?? 12,
|
||||
}
|
||||
}
|
||||
|
||||
export const setFreeTrialConfig = async ({ enabled, duration_minutes }) => {
|
||||
return setFirstSessionDiscountConfig({
|
||||
...(enabled !== undefined ? { enabled } : {}),
|
||||
...(duration_minutes !== undefined ? { duration_minutes } : {}),
|
||||
})
|
||||
// Build a sparse UPDATE so undefined fields are left alone (matches the prior
|
||||
// semantics where missing patch fields were no-ops). Use COALESCE on each
|
||||
// column with the sentinel-when-undefined pattern; postgres.js parameterizes
|
||||
// null/undefined identically, so we branch on which fields the caller sent.
|
||||
if (enabled === undefined && duration_minutes === undefined) {
|
||||
return getFreeTrialConfig()
|
||||
}
|
||||
|
||||
await sql`
|
||||
UPDATE pricing_promotions
|
||||
SET enabled = ${enabled === undefined ? sql`enabled` : enabled},
|
||||
duration_minutes = ${duration_minutes === undefined ? sql`duration_minutes` : duration_minutes},
|
||||
updated_at = NOW()
|
||||
WHERE eligibility = 'first_session'
|
||||
`
|
||||
return getFreeTrialConfig()
|
||||
}
|
||||
|
||||
// --- Phase 4: Support handles ---
|
||||
@@ -120,30 +104,6 @@ export const setSupportHandles = async ({ wa, telegram }) => {
|
||||
return next
|
||||
}
|
||||
|
||||
// --- Phase 4: Pricing tier groups ---
|
||||
|
||||
export const getPricingTierGroups = async () => {
|
||||
const rows = await sql`
|
||||
SELECT key, value FROM app_config
|
||||
WHERE key IN ('pricing_chat_tiers_json', 'pricing_call_tiers_json')
|
||||
`
|
||||
const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value]))
|
||||
return {
|
||||
chat: byKey.pricing_chat_tiers_json?.tiers ?? [],
|
||||
call: byKey.pricing_call_tiers_json?.tiers ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export const setPricingTierGroup = async (mode, tiers) => {
|
||||
const key = mode === 'call' ? 'pricing_call_tiers_json' : 'pricing_chat_tiers_json'
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value, updated_at)
|
||||
VALUES (${key}, ${sql.json({ tiers })}, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`
|
||||
return getPricingTierGroups()
|
||||
}
|
||||
|
||||
export const getExtensionTimeoutConfig = async () => {
|
||||
const [row] = await sql`SELECT value FROM app_config WHERE key = 'extension_timeout_seconds'`
|
||||
// Default 10s pairs with the auto-approve-on-timeout flow; raise this if you change the policy to auto-reject.
|
||||
|
||||
@@ -3,8 +3,13 @@ import { SessionStatus } from '../constants.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
// Default tiers as fallback (used if app_config row is missing). Match the seed
|
||||
// values in migrate.js so a missing row never breaks pricing in the wild.
|
||||
// Default tiers as fallback (used if pricing_tiers is empty — e.g. fresh dev DB or
|
||||
// test fixture without backfill). Match the DEFAULT_*_BACKFILL arrays in migrate.js
|
||||
// so a missing row never breaks pricing in the wild. Stage 3 keeps these as a
|
||||
// belt-and-suspenders only — `id` here is the legacy bare string, which Stage 3
|
||||
// preserves for the "no rows" edge case so the customer-facing shape (UUID strings
|
||||
// once populated, bare strings only in this degraded fallback path) keeps the
|
||||
// same JSON keys.
|
||||
const DEFAULT_CHAT_TIERS = [
|
||||
{ id: '5', minutes: 5, price_idr: 5000, tag: null },
|
||||
{ id: '12', minutes: 12, price_idr: 12000, tag: 'paling pas' },
|
||||
@@ -26,34 +31,60 @@ const DEFAULT_DISCOUNT = {
|
||||
modes: ['chat'],
|
||||
}
|
||||
|
||||
// Map a pricing_tiers row to the customer-facing tier object. `original_price_idr`
|
||||
// is deliberately omitted from the public shape (Stage 3 keeps it schema-only).
|
||||
const toCustomerTier = (row) => ({
|
||||
id: row.id,
|
||||
tag: row.tag,
|
||||
minutes: row.minutes,
|
||||
price_idr: row.price_idr,
|
||||
})
|
||||
|
||||
const readChatTiers = async () => {
|
||||
const [row] = await sql`SELECT value FROM app_config WHERE key = 'pricing_chat_tiers_json'`
|
||||
return row?.value?.tiers ?? DEFAULT_CHAT_TIERS
|
||||
const rows = await sql`
|
||||
SELECT id, minutes, price_idr, tag, sort_order
|
||||
FROM pricing_tiers
|
||||
WHERE mode = 'chat' AND is_active = true
|
||||
ORDER BY sort_order ASC, minutes ASC
|
||||
`
|
||||
if (rows.length === 0) return DEFAULT_CHAT_TIERS
|
||||
return rows.map(toCustomerTier)
|
||||
}
|
||||
|
||||
const readCallTiers = async () => {
|
||||
const [row] = await sql`SELECT value FROM app_config WHERE key = 'pricing_call_tiers_json'`
|
||||
return row?.value?.tiers ?? DEFAULT_CALL_TIERS
|
||||
const rows = await sql`
|
||||
SELECT id, minutes, price_idr, tag, sort_order
|
||||
FROM pricing_tiers
|
||||
WHERE mode = 'call' AND is_active = true
|
||||
ORDER BY sort_order ASC, minutes ASC
|
||||
`
|
||||
if (rows.length === 0) return DEFAULT_CALL_TIERS
|
||||
return rows.map(toCustomerTier)
|
||||
}
|
||||
|
||||
const readDiscountConfig = async () => {
|
||||
const keys = [
|
||||
'first_session_discount_enabled',
|
||||
'first_session_discount_actual_price_idr',
|
||||
'first_session_discount_gimmick_price_idr',
|
||||
'first_session_discount_duration_minutes',
|
||||
'first_session_discount_modes',
|
||||
]
|
||||
const rows = await sql`
|
||||
SELECT key, value FROM app_config WHERE key IN ${sql(keys)}
|
||||
/**
|
||||
* Read the canonical first-session-discount config from `pricing_promotions`
|
||||
* (eligibility = 'first_session'). Falls back to DEFAULT_DISCOUNT if the row
|
||||
* is missing (fresh dev DB or partial migration).
|
||||
*
|
||||
* Exported because the customer-facing payment-session route needs the exact
|
||||
* same source-of-truth as `getPricingForCustomer` and the eligibility predicate
|
||||
* — duplicating the read in route code would be a foot-gun for operator edits
|
||||
* in CC (Stage 5 moved this off `app_config` JSON to the relational table).
|
||||
*/
|
||||
export const readFirstSessionDiscountConfig = async () => {
|
||||
const [row] = await sql`
|
||||
SELECT enabled, actual_price_idr, gimmick_price_idr, duration_minutes, modes
|
||||
FROM pricing_promotions
|
||||
WHERE eligibility = 'first_session'
|
||||
`
|
||||
const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value?.value]))
|
||||
if (!row) return DEFAULT_DISCOUNT
|
||||
return {
|
||||
enabled: byKey.first_session_discount_enabled ?? DEFAULT_DISCOUNT.enabled,
|
||||
actual_price_idr: byKey.first_session_discount_actual_price_idr ?? DEFAULT_DISCOUNT.actual_price_idr,
|
||||
gimmick_price_idr: byKey.first_session_discount_gimmick_price_idr ?? DEFAULT_DISCOUNT.gimmick_price_idr,
|
||||
duration_minutes: byKey.first_session_discount_duration_minutes ?? DEFAULT_DISCOUNT.duration_minutes,
|
||||
modes: byKey.first_session_discount_modes ?? DEFAULT_DISCOUNT.modes,
|
||||
enabled: row.enabled,
|
||||
actual_price_idr: row.actual_price_idr,
|
||||
gimmick_price_idr: row.gimmick_price_idr,
|
||||
duration_minutes: row.duration_minutes,
|
||||
modes: row.modes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +92,7 @@ const readDiscountConfig = async () => {
|
||||
* Per-customer first-session-discount eligibility.
|
||||
*
|
||||
* Predicate (Phase 4):
|
||||
* - app_config.first_session_discount_enabled == true, AND
|
||||
* - pricing_promotions.enabled (for eligibility='first_session') == true, AND
|
||||
* - customer is phone-verified (customers.phone IS NOT NULL — phone only gets set
|
||||
* via the OTP-verify path, so non-null is proof of verification), AND
|
||||
* - customer has no completed/closing chat_sessions row (returning users pay full price).
|
||||
@@ -70,7 +101,7 @@ const readDiscountConfig = async () => {
|
||||
* column. `phone IS NOT NULL` is the equivalent invariant in this schema.
|
||||
*/
|
||||
export const isCustomerEligibleForFirstSessionDiscount = async (customerId) => {
|
||||
const discount = await readDiscountConfig()
|
||||
const discount = await readFirstSessionDiscountConfig()
|
||||
if (!discount.enabled) return false
|
||||
|
||||
const [customer] = await sql`
|
||||
@@ -103,7 +134,7 @@ export const getPricingForCustomer = async (customerId) => {
|
||||
const [chatTiers, callTiers, discount, eligible] = await Promise.all([
|
||||
readChatTiers(),
|
||||
readCallTiers(),
|
||||
readDiscountConfig(),
|
||||
readFirstSessionDiscountConfig(),
|
||||
isCustomerEligibleForFirstSessionDiscount(customerId),
|
||||
])
|
||||
return {
|
||||
@@ -124,7 +155,17 @@ export const getPricingForCustomer = async (customerId) => {
|
||||
* 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()
|
||||
const [row] = await sql`
|
||||
SELECT id FROM pricing_tiers
|
||||
WHERE mode = ${mode}
|
||||
AND minutes = ${durationMinutes}
|
||||
AND price_idr = ${priceIdr}
|
||||
AND is_active = true
|
||||
LIMIT 1
|
||||
`
|
||||
if (row) return true
|
||||
// Fallback: empty table → use in-memory defaults. Keeps tests with a clean DB green.
|
||||
const tiers = mode === 'call' ? DEFAULT_CALL_TIERS : DEFAULT_CHAT_TIERS
|
||||
return tiers.some((t) => t.minutes === durationMinutes && t.price_idr === priceIdr)
|
||||
}
|
||||
|
||||
@@ -132,7 +173,17 @@ export const isValidTier = async ({ mode, durationMinutes, 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()
|
||||
const [row] = await sql`
|
||||
SELECT id, minutes, price_idr, tag
|
||||
FROM pricing_tiers
|
||||
WHERE mode = ${mode}
|
||||
AND minutes = ${durationMinutes}
|
||||
AND is_active = true
|
||||
LIMIT 1
|
||||
`
|
||||
if (row) return { id: row.id, minutes: row.minutes, price_idr: row.price_idr, tag: row.tag }
|
||||
// Fallback: empty table → in-memory defaults.
|
||||
const tiers = mode === 'call' ? DEFAULT_CALL_TIERS : DEFAULT_CHAT_TIERS
|
||||
return tiers.find((t) => t.minutes === durationMinutes) ?? null
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user