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