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:
2026-05-16 00:12:11 +08:00
parent a09f37135c
commit 1c9d81d81d
16 changed files with 3076 additions and 316 deletions

View File

@@ -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 ---

View File

@@ -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 (