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

@@ -44,3 +44,16 @@ export const ExtensionTimeoutAction = Object.freeze({
AUTO_APPROVE: 'auto_approve',
AUTO_REJECT: 'auto_reject',
})
// Session / pricing modes. Mirrors backend `SessionMode` enum.
export const SessionMode = Object.freeze({
CHAT: 'chat',
CALL: 'call',
})
// Mirror of backend error codes returned on optimistic-lock conflict / validation.
export const ApiErrorCode = Object.freeze({
STALE_WRITE: 'STALE_WRITE',
VALIDATION: 'VALIDATION',
NOT_FOUND: 'NOT_FOUND',
})