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

@@ -0,0 +1,349 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
// The internal app doesn't pull the WS plugin but does pull notification.service via
// session routes. Mock both for parity with other route tests + safety.
vi.mock('../../../src/plugins/websocket.js', () => ({
sendToUser: vi.fn(() => false),
sendToSessionParticipant: vi.fn(() => false),
registerWebSocketPlugin: vi.fn(async () => {}),
registerWebSocketRoute: vi.fn(),
isUserOnlineWs: vi.fn(() => false),
getSessionConnections: vi.fn(() => ({})),
}))
vi.mock('../../../src/services/notification.service.js', () => ({
sendPushNotification: vi.fn(async () => true),
registerDeviceToken: vi.fn(async () => {}),
}))
const { buildInternal } = await import('../../helpers/server.js')
const { resetAppConfig, db } = await import('../../helpers/db.js')
const { createCcUser } = await import('../../helpers/fixtures.js')
const { ccJwt, authHeader } = await import('../../helpers/jwt.js')
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/**
* Stage 3 route tests for the relational pricing CRUD endpoints.
*
* We do NOT use resetDb() because:
* 1. resetDb() doesn't truncate pricing_tiers (deliberate — backfill is expensive).
* 2. The migration already seeded the canonical chat+call catalog at setup time.
*
* Tests that mutate the catalog use sql DELETE/UPDATE inside the test body and clean
* up in afterEach. Tests that read the catalog rely on the seeded rows.
*/
describe('/internal/config/pricing-tiers', () => {
let app
let ccUser
let token
beforeAll(async () => {
await resetAppConfig()
app = await buildInternal()
ccUser = await createCcUser({ displayName: 'PricingTierOperator' })
token = ccJwt(ccUser.id)
})
// Track tier UUIDs we POST/seed across ALL tests in this file so afterAll can
// clean them up without disturbing the canonical seeded catalog. Deliberately
// NOT reset between tests — every test that creates a row appends and the IDs
// accumulate; afterAll iterates and DELETEs them at the end.
const createdTierIds = []
afterAll(async () => {
const sql = db()
if (createdTierIds.length > 0) {
await sql`DELETE FROM pricing_tiers_history WHERE tier_id = ANY(${createdTierIds})`
await sql`DELETE FROM pricing_tiers WHERE id = ANY(${createdTierIds})`
}
// Defense-in-depth: also wipe any non-canonical chat/call tiers that slipped through.
// The canonical catalog is the 5 chat + 4 call minutes seeded by migrate.js.
await sql`
DELETE FROM pricing_tiers
WHERE (mode = 'chat' AND minutes NOT IN (5, 12, 30, 60, 120))
OR (mode = 'call' AND minutes NOT IN (10, 20, 45, 60))
`
await app?.close()
})
describe('GET /internal/config/pricing-tiers', () => {
it('returns the canonical chat + call catalog with updated_at on every row', async () => {
const res = await app.inject({
method: 'GET',
url: '/internal/config/pricing-tiers',
headers: authHeader(token),
})
expect(res.statusCode).toBe(200)
const body = res.json()
expect(body.success).toBe(true)
expect(Array.isArray(body.data.chat)).toBe(true)
expect(Array.isArray(body.data.call)).toBe(true)
expect(body.data.chat.length).toBeGreaterThan(0)
expect(body.data.call.length).toBeGreaterThan(0)
const sample = body.data.chat[0]
expect(UUID_RE.test(sample.id)).toBe(true)
expect(typeof sample.minutes).toBe('number')
expect(typeof sample.price_idr).toBe('number')
expect('original_price_idr' in sample).toBe(true) // schema-only, but operator-facing GET exposes it
expect('updated_at' in sample).toBe(true)
expect('is_active' in sample).toBe(true)
})
it('includes is_active=false tiers (operators must be able to see and re-activate them)', async () => {
const sql = db()
// Create a soft-deleted tier and verify it shows up in the internal GET.
const [row] = await sql`
INSERT INTO pricing_tiers (mode, minutes, price_idr, sort_order, is_active)
VALUES ('chat', 777, 77000, 99, false)
RETURNING id
`
createdTierIds.push(row.id)
const res = await app.inject({
method: 'GET',
url: '/internal/config/pricing-tiers',
headers: authHeader(token),
})
const ids = res.json().data.chat.map((t) => t.id)
expect(ids).toContain(row.id)
const inactive = res.json().data.chat.find((t) => t.id === row.id)
expect(inactive.is_active).toBe(false)
})
})
describe('POST /internal/config/pricing-tiers — create', () => {
it('happy path: creates a tier, writes a history row with change_kind=create, returns 201', async () => {
const res = await app.inject({
method: 'POST',
url: '/internal/config/pricing-tiers',
headers: authHeader(token),
payload: { mode: 'chat', minutes: 45, price_idr: 33000, tag: 'test-create', sort_order: 99 },
})
expect(res.statusCode).toBe(201)
const body = res.json()
expect(body.success).toBe(true)
expect(UUID_RE.test(body.data.id)).toBe(true)
expect(body.data.mode).toBe('chat')
expect(body.data.minutes).toBe(45)
expect(body.data.price_idr).toBe(33000)
expect(body.data.tag).toBe('test-create')
expect(body.data.is_active).toBe(true)
createdTierIds.push(body.data.id)
// History row was written in the same transaction.
const sql = db()
const history = await sql`
SELECT change_kind, changed_by, price_idr, minutes, mode
FROM pricing_tiers_history WHERE tier_id = ${body.data.id}
`
expect(history).toHaveLength(1)
expect(history[0].change_kind).toBe('create')
expect(history[0].changed_by).toBe(ccUser.id)
expect(history[0].price_idr).toBe(33000)
})
it('422 on duplicate (mode, minutes)', async () => {
const sql = db()
const [existing] = await sql`SELECT mode, minutes FROM pricing_tiers WHERE mode = 'chat' AND minutes = 12`
expect(existing).toBeDefined()
const res = await app.inject({
method: 'POST',
url: '/internal/config/pricing-tiers',
headers: authHeader(token),
payload: { mode: 'chat', minutes: 12, price_idr: 99999 },
})
expect(res.statusCode).toBe(422)
expect(res.json().error.code).toBe('VALIDATION')
})
it('422 on negative price', async () => {
const res = await app.inject({
method: 'POST',
url: '/internal/config/pricing-tiers',
headers: authHeader(token),
payload: { mode: 'chat', minutes: 88, price_idr: -1 },
})
expect(res.statusCode).toBe(422)
expect(res.json().error.field).toBe('price_idr')
})
it('422 on non-positive minutes', async () => {
const res = await app.inject({
method: 'POST',
url: '/internal/config/pricing-tiers',
headers: authHeader(token),
payload: { mode: 'chat', minutes: 0, price_idr: 1000 },
})
expect(res.statusCode).toBe(422)
expect(res.json().error.field).toBe('minutes')
})
})
describe('PATCH /internal/config/pricing-tiers/:id — update with optimistic-lock', () => {
it('happy path: updates price_idr and writes update history with correct changed_by', async () => {
// Create a fresh tier we own so we don't mutate the canonical catalog.
const createRes = await app.inject({
method: 'POST',
url: '/internal/config/pricing-tiers',
headers: authHeader(token),
payload: { mode: 'chat', minutes: 91, price_idr: 9100 },
})
const created = createRes.json().data
createdTierIds.push(created.id)
const patchRes = await app.inject({
method: 'PATCH',
url: `/internal/config/pricing-tiers/${created.id}`,
headers: authHeader(token),
payload: { updated_at: created.updated_at, price_idr: 9999, tag: 'updated' },
})
expect(patchRes.statusCode).toBe(200)
const updated = patchRes.json().data
expect(updated.price_idr).toBe(9999)
expect(updated.tag).toBe('updated')
expect(new Date(updated.updated_at).getTime()).toBeGreaterThanOrEqual(new Date(created.updated_at).getTime())
const sql = db()
const history = await sql`
SELECT change_kind, changed_by, price_idr
FROM pricing_tiers_history WHERE tier_id = ${created.id} ORDER BY changed_at ASC
`
expect(history).toHaveLength(2)
expect(history[0].change_kind).toBe('create')
expect(history[1].change_kind).toBe('update')
expect(history[1].price_idr).toBe(9999)
expect(history[1].changed_by).toBe(ccUser.id)
})
it('409 STALE_WRITE on mismatched updated_at, with server_updated_at in the error', async () => {
const createRes = await app.inject({
method: 'POST',
url: '/internal/config/pricing-tiers',
headers: authHeader(token),
payload: { mode: 'chat', minutes: 92, price_idr: 9200 },
})
const created = createRes.json().data
createdTierIds.push(created.id)
const stale = new Date(new Date(created.updated_at).getTime() - 60_000).toISOString()
const patchRes = await app.inject({
method: 'PATCH',
url: `/internal/config/pricing-tiers/${created.id}`,
headers: authHeader(token),
payload: { updated_at: stale, price_idr: 1 },
})
expect(patchRes.statusCode).toBe(409)
const err = patchRes.json().error
expect(err.code).toBe('STALE_WRITE')
expect(err.server_updated_at).toBeDefined()
})
it('404 on unknown UUID', async () => {
const res = await app.inject({
method: 'PATCH',
url: '/internal/config/pricing-tiers/00000000-0000-0000-0000-000000000000',
headers: authHeader(token),
payload: { updated_at: new Date().toISOString(), price_idr: 1 },
})
expect(res.statusCode).toBe(404)
expect(res.json().error.code).toBe('NOT_FOUND')
})
it('422 when id is not a UUID', async () => {
const res = await app.inject({
method: 'PATCH',
url: '/internal/config/pricing-tiers/not-a-uuid',
headers: authHeader(token),
payload: { updated_at: new Date().toISOString(), price_idr: 1 },
})
expect(res.statusCode).toBe(422)
expect(res.json().error.field).toBe('id')
})
it('422 when updated_at is missing', async () => {
const createRes = await app.inject({
method: 'POST',
url: '/internal/config/pricing-tiers',
headers: authHeader(token),
payload: { mode: 'chat', minutes: 93, price_idr: 9300 },
})
const created = createRes.json().data
createdTierIds.push(created.id)
const res = await app.inject({
method: 'PATCH',
url: `/internal/config/pricing-tiers/${created.id}`,
headers: authHeader(token),
payload: { price_idr: 1 },
})
expect(res.statusCode).toBe(422)
expect(res.json().error.field).toBe('updated_at')
})
})
describe('DELETE /internal/config/pricing-tiers/:id — soft delete', () => {
it('flips is_active=false and writes a delete history row', async () => {
const createRes = await app.inject({
method: 'POST',
url: '/internal/config/pricing-tiers',
headers: authHeader(token),
payload: { mode: 'chat', minutes: 94, price_idr: 9400 },
})
const created = createRes.json().data
createdTierIds.push(created.id)
const delRes = await app.inject({
method: 'DELETE',
url: `/internal/config/pricing-tiers/${created.id}`,
headers: authHeader(token),
payload: { updated_at: created.updated_at },
})
expect(delRes.statusCode).toBe(200)
expect(delRes.json().data.is_active).toBe(false)
const sql = db()
const history = await sql`
SELECT change_kind, is_active, changed_by
FROM pricing_tiers_history WHERE tier_id = ${created.id} ORDER BY changed_at ASC
`
const last = history[history.length - 1]
expect(last.change_kind).toBe('delete')
// Per the contract: snapshot reflects POST-state, so is_active=false.
expect(last.is_active).toBe(false)
expect(last.changed_by).toBe(ccUser.id)
})
it('409 STALE_WRITE on mismatched updated_at', async () => {
const createRes = await app.inject({
method: 'POST',
url: '/internal/config/pricing-tiers',
headers: authHeader(token),
payload: { mode: 'chat', minutes: 95, price_idr: 9500 },
})
const created = createRes.json().data
createdTierIds.push(created.id)
const stale = new Date(new Date(created.updated_at).getTime() - 60_000).toISOString()
const res = await app.inject({
method: 'DELETE',
url: `/internal/config/pricing-tiers/${created.id}`,
headers: authHeader(token),
payload: { updated_at: stale },
})
expect(res.statusCode).toBe(409)
expect(res.json().error.code).toBe('STALE_WRITE')
})
it('404 on unknown UUID', async () => {
const res = await app.inject({
method: 'DELETE',
url: '/internal/config/pricing-tiers/00000000-0000-0000-0000-000000000000',
headers: authHeader(token),
payload: { updated_at: new Date().toISOString() },
})
expect(res.statusCode).toBe(404)
})
})
})