Phase 5.x payment catalog + customer-app splash/register polish

Payment catalog (Phase 5.x — see requirement/phase5-payment-catalog-plan.md):
- New tables payment_method_groups + payment_methods with seed (3 groups,
  10 methods; GoPay seeded inactive pending Xendit channel confirmation).
- payment-catalog.service.js with two-layer cache (60s in-process + 1h
  Valkey) and config:invalidate pub/sub fanout. Mutator API + casing-
  tolerant findActiveMethodByCode for downstream validation.
- App-facing GET /api/client/payment-methods returns pre-grouped JSON,
  active-only, empty groups dropped server-side.
- POST /api/client/payment-requests now validates `method` against the
  catalog (INVALID_PAYMENT_METHOD 422) and stamps
  product_metadata.preferred_payment_code (upper-cased).
- Control-center /internal/payment-{groups,methods}{,/:id,/reorder}
  endpoints (full CRUD + idempotent reorder). New Payment Catalog page
  wired into the CC nav.
- Customer app renders the catalog as collapsible groups (first expanded)
  via paymentCatalogProvider; QRIS-only hardcoded fallback on 5xx so
  checkout never hard-fails. Replaces the hardcoded _PayMethod enum.
- 10 brand SVGs (~63KB) bundled in client_app/assets/payment_icons/ from
  github.com/hafidznoor/idn-finlogos. Xendit's per-channel media-asset
  pages were planned but found decommissioned during implementation —
  switched to idn-finlogos with the standard "channels-we-accept"
  trademark posture. See assets/payment_icons/README.md for the workflow
  to add new methods.
- 16 vitest cases covering the service + cache; full backend suite green
  (162/162).

Customer-app splash + register polish:
- Splash rewritten per figma S1: warm vertical gradient, two ImageFiltered
  radial orbs, 96×96 rounded-square logo tile, "HaloBestie" + "kamu gak
  harus ngerasain ini sendirian." Self-driving navigation via context.go
  after a 2.5s post-frame timer (native Android splash burns ~1-1.5s
  before Flutter paints — 1s timer yielded near-zero visible duration).
  Router early-returns null for isSplash so it never moves us off /splash
  on its own.
- 3-page onboarding carousel removed: user clarified the new splash
  REPLACES that carousel. Dropped /onboarding route, OnboardingScreen,
  onboardingDoneProvider + gating, dead splash_{1,2,3}.png + the
  splash_chat_hebat.png Flutter asset. Phase 4 /onboarding/* subroutes
  untouched; Android-native launch_background drawable left alone.
- Register screen (login-by-phone) polished: circular pink back button +
  72×72 logo badge (same brandLogoBg pink as splash, Transform.scale 1.4
  to fill the tile). Step-dots indicator removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 23:06:46 +08:00
parent d60c048776
commit 1f6d8e09ae
39 changed files with 2634 additions and 370 deletions

View File

@@ -0,0 +1,254 @@
/**
* Control-center routes for the payment catalog — Phase 5.x.
*
* Routes
* GET /internal/payment-groups
* POST /internal/payment-groups — create
* PATCH /internal/payment-groups/:id — partial update
* DELETE /internal/payment-groups/:id — 409 if any method still references it
* POST /internal/payment-groups/reorder — idempotent reorder (full ordered array)
*
* GET /internal/payment-methods
* GET /internal/payment-methods?group_id=...
* POST /internal/payment-methods — create
* PATCH /internal/payment-methods/:id — partial update
* DELETE /internal/payment-methods/:id
* POST /internal/payment-methods/reorder
*
* Authz: every route requires `config` `read` or `update` per CC permissions —
* same scope used by `internalConfigRoutes` so an operator who can edit pricing
* can also edit the payment catalog without provisioning a new permission key.
*/
import { authenticate, requirePermission } from '../../plugins/auth.js'
import { getCcUserById } from '../../services/cc-user.service.js'
import { UserType } from '../../constants.js'
import {
listGroups,
listMethods,
createGroup,
updateGroup,
deleteGroup,
reorderGroups,
createMethod,
updateMethod,
deleteMethod,
reorderMethods,
} from '../../services/payment-catalog.service.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
const validation = (message, field) => ({
success: false,
error: { code: 'VALIDATION', message, ...(field ? { field } : {}) },
})
const notFound = (message = 'Not found') => ({
success: false,
error: { code: 'NOT_FOUND', message },
})
const conflict = (message, extra = {}) => ({
success: false,
error: { code: 'CONFLICT', message, ...extra },
})
const attachCcUser = async (request, reply) => {
if (request.auth?.userType !== UserType.CC_USER) {
return reply.code(403).send({
success: false,
error: { code: 'FORBIDDEN', message: 'Not a control center user' },
})
}
const user = await getCcUserById(request.auth.userId)
if (!user) {
return reply.code(403).send({
success: false,
error: { code: 'FORBIDDEN', message: 'Not a control center user' },
})
}
request.ccUser = user
}
const READ_GUARD = [authenticate, attachCcUser, requirePermission('config', 'read')]
const WRITE_GUARD = [authenticate, attachCcUser, requirePermission('config', 'update')]
export const internalPaymentCatalogRoutes = async (app) => {
// ─────────────── Groups ───────────────
app.get('/payment-groups', { preHandler: READ_GUARD }, async (_request, reply) => {
const groups = await listGroups()
return reply.send({ success: true, data: { groups } })
})
app.post('/payment-groups', { preHandler: WRITE_GUARD }, async (request, reply) => {
const { name, display_order, is_active } = request.body ?? {}
if (typeof name !== 'string' || name.trim().length === 0) {
return reply.code(422).send(validation('name is required', 'name'))
}
const row = await createGroup({
name: name.trim(),
displayOrder: Number.isFinite(display_order) ? display_order : 0,
isActive: typeof is_active === 'boolean' ? is_active : true,
})
return reply.code(201).send({ success: true, data: row })
})
app.patch('/payment-groups/:id', { preHandler: WRITE_GUARD }, async (request, reply) => {
const { id } = request.params
if (!UUID_RE.test(id)) {
return reply.code(422).send(validation('Invalid id format', 'id'))
}
const { name, display_order, is_active } = request.body ?? {}
if (name !== undefined && (typeof name !== 'string' || name.trim().length === 0)) {
return reply.code(422).send(validation('name must be a non-empty string', 'name'))
}
const row = await updateGroup(id, {
name: name?.trim(),
displayOrder: Number.isFinite(display_order) ? display_order : undefined,
isActive: typeof is_active === 'boolean' ? is_active : undefined,
})
if (!row) return reply.code(404).send(notFound('payment_method_group not found'))
return reply.send({ success: true, data: row })
})
app.delete('/payment-groups/:id', { preHandler: WRITE_GUARD }, async (request, reply) => {
const { id } = request.params
if (!UUID_RE.test(id)) {
return reply.code(422).send(validation('Invalid id format', 'id'))
}
try {
const row = await deleteGroup(id)
if (!row) return reply.code(404).send(notFound('payment_method_group not found'))
return reply.send({ success: true, data: { id: row.id } })
} catch (err) {
// FK violation from ON DELETE RESTRICT → bubbles up as 23503.
if (err?.code === '23503') {
return reply.code(409).send(conflict(
'Cannot delete group while methods still reference it. Move or delete the methods first.',
{ pg_code: err.code },
))
}
throw err
}
})
app.post('/payment-groups/reorder', { preHandler: WRITE_GUARD }, async (request, reply) => {
const { ordered_ids } = request.body ?? {}
if (!Array.isArray(ordered_ids) || ordered_ids.length === 0) {
return reply.code(422).send(validation('ordered_ids must be a non-empty array', 'ordered_ids'))
}
if (!ordered_ids.every((id) => typeof id === 'string' && UUID_RE.test(id))) {
return reply.code(422).send(validation('All ordered_ids must be UUIDs', 'ordered_ids'))
}
await reorderGroups(ordered_ids)
return reply.send({ success: true })
})
// ─────────────── Methods ───────────────
app.get('/payment-methods', { preHandler: READ_GUARD }, async (request, reply) => {
const groupId = request.query?.group_id
if (groupId !== undefined && (typeof groupId !== 'string' || !UUID_RE.test(groupId))) {
return reply.code(422).send(validation('group_id must be a UUID', 'group_id'))
}
const methods = await listMethods({ groupId: groupId ?? null })
return reply.send({ success: true, data: { methods } })
})
app.post('/payment-methods', { preHandler: WRITE_GUARD }, async (request, reply) => {
const { group_id, display_name, payment_code, display_order, icon, is_active } = request.body ?? {}
if (typeof group_id !== 'string' || !UUID_RE.test(group_id)) {
return reply.code(422).send(validation('group_id is required and must be a UUID', 'group_id'))
}
if (typeof display_name !== 'string' || display_name.trim().length === 0) {
return reply.code(422).send(validation('display_name is required', 'display_name'))
}
if (typeof payment_code !== 'string' || payment_code.trim().length === 0) {
return reply.code(422).send(validation('payment_code is required', 'payment_code'))
}
try {
const row = await createMethod({
groupId: group_id,
displayName: display_name.trim(),
paymentCode: payment_code.trim(),
displayOrder: Number.isFinite(display_order) ? display_order : 0,
icon: typeof icon === 'string' && icon.trim().length > 0 ? icon.trim() : null,
isActive: typeof is_active === 'boolean' ? is_active : true,
})
return reply.code(201).send({ success: true, data: row })
} catch (err) {
// Unique violation on payment_code.
if (err?.code === '23505') {
return reply.code(409).send(conflict(
`payment_code already exists (case-insensitive)`,
{ pg_code: err.code },
))
}
// FK violation on group_id.
if (err?.code === '23503') {
return reply.code(422).send(validation('group_id does not reference an existing group', 'group_id'))
}
throw err
}
})
app.patch('/payment-methods/:id', { preHandler: WRITE_GUARD }, async (request, reply) => {
const { id } = request.params
if (!UUID_RE.test(id)) {
return reply.code(422).send(validation('Invalid id format', 'id'))
}
const { group_id, display_name, payment_code, display_order, icon, is_active } = request.body ?? {}
if (group_id !== undefined && (typeof group_id !== 'string' || !UUID_RE.test(group_id))) {
return reply.code(422).send(validation('group_id must be a UUID', 'group_id'))
}
if (display_name !== undefined && (typeof display_name !== 'string' || display_name.trim().length === 0)) {
return reply.code(422).send(validation('display_name must be non-empty', 'display_name'))
}
if (payment_code !== undefined && (typeof payment_code !== 'string' || payment_code.trim().length === 0)) {
return reply.code(422).send(validation('payment_code must be non-empty', 'payment_code'))
}
try {
const row = await updateMethod(id, {
groupId: group_id,
displayName: display_name?.trim(),
paymentCode: payment_code?.trim(),
displayOrder: Number.isFinite(display_order) ? display_order : undefined,
icon: icon === null ? null : (typeof icon === 'string' && icon.trim().length > 0 ? icon.trim() : undefined),
isActive: typeof is_active === 'boolean' ? is_active : undefined,
})
if (!row) return reply.code(404).send(notFound('payment_method not found'))
return reply.send({ success: true, data: row })
} catch (err) {
if (err?.code === '23505') {
return reply.code(409).send(conflict('payment_code already exists (case-insensitive)'))
}
if (err?.code === '23503') {
return reply.code(422).send(validation('group_id does not reference an existing group', 'group_id'))
}
throw err
}
})
app.delete('/payment-methods/:id', { preHandler: WRITE_GUARD }, async (request, reply) => {
const { id } = request.params
if (!UUID_RE.test(id)) {
return reply.code(422).send(validation('Invalid id format', 'id'))
}
const row = await deleteMethod(id)
if (!row) return reply.code(404).send(notFound('payment_method not found'))
return reply.send({ success: true, data: { id: row.id } })
})
app.post('/payment-methods/reorder', { preHandler: WRITE_GUARD }, async (request, reply) => {
const { ordered_ids } = request.body ?? {}
if (!Array.isArray(ordered_ids) || ordered_ids.length === 0) {
return reply.code(422).send(validation('ordered_ids must be a non-empty array', 'ordered_ids'))
}
if (!ordered_ids.every((id) => typeof id === 'string' && UUID_RE.test(id))) {
return reply.code(422).send(validation('All ordered_ids must be UUIDs', 'ordered_ids'))
}
await reorderMethods(ordered_ids)
return reply.send({ success: true })
})
}

View File

@@ -0,0 +1,19 @@
/**
* App-facing payment catalog endpoint.
*
* GET /api/client/payment-methods → { groups: [ ... ] }
*
* Cached + active-filtered (see payment-catalog.service.js::getCatalogForApp).
* Authenticated; the customer app already has a JWT by the time it reaches
* the payment-method screen.
*/
import { authenticate } from '../../plugins/auth.js'
import { getCatalogForApp } from '../../services/payment-catalog.service.js'
export const clientPaymentMethodsRoutes = (app) => {
app.get('/', { preHandler: authenticate }, async (_request, reply) => {
const catalog = await getCatalogForApp()
return reply.send({ success: true, data: catalog })
})
}

View File

@@ -14,6 +14,7 @@ import {
readFirstSessionDiscountConfig,
} from '../../services/pricing.service.js'
import { getXenditConfig } from '../../services/config.service.js'
import { findActiveMethodByCode } from '../../services/payment-catalog.service.js'
import { UserType, SessionMode } from '../../constants.js'
const resolveCustomer = async (request, reply) => {
@@ -51,8 +52,32 @@ export const clientPaymentRoutes = async (app) => {
mode = SessionMode.CHAT,
targeted_mitra_id = null,
is_extension = false,
method = null,
} = request.body ?? {}
// Catalog validation — the customer's pre-pick (set in payment_method_screen.dart)
// must reference an active row in `payment_methods`. Casing-tolerant; older app
// versions sending lower-case (`qris`) are normalised inside the service.
// `method` is optional for backwards compat with pre-Phase-5.x callers.
if (method !== null && method !== undefined) {
if (typeof method !== 'string' || method.trim().length === 0) {
return reply.code(422).send({
success: false,
error: { code: 'VALIDATION_ERROR', message: 'method must be a non-empty string when provided' },
})
}
const entry = await findActiveMethodByCode(method)
if (!entry) {
return reply.code(422).send({
success: false,
error: {
code: 'INVALID_PAYMENT_METHOD',
message: 'Selected payment method is not available',
},
})
}
}
if (typeof duration_minutes !== 'number' || duration_minutes <= 0) {
return reply.code(422).send({
success: false,
@@ -116,6 +141,7 @@ export const clientPaymentRoutes = async (app) => {
isExtension: Boolean(is_extension),
targetedMitraId: targeted_mitra_id || null,
mode,
preferredPaymentCode: method ? String(method).toUpperCase() : null,
})
return reply.code(201).send({