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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user