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