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:
@@ -10,6 +10,7 @@ import { internalConfigRoutes } from './routes/internal/config.routes.js'
|
||||
import { sessionManagementRoutes } from './routes/internal/session.routes.js'
|
||||
import { mitraActivityRoutes } from './routes/internal/mitra-activity.routes.js'
|
||||
import { failedPairingsRoutes } from './routes/internal/failed-pairings.routes.js'
|
||||
import { internalPaymentCatalogRoutes } from './routes/internal/payment-catalog.routes.js'
|
||||
import { internalTestRoutes } from './routes/internal/_test.routes.js'
|
||||
import { errorHandler } from './plugins/error-handler.js'
|
||||
|
||||
@@ -38,6 +39,7 @@ export const buildInternalApp = async () => {
|
||||
app.register(sessionManagementRoutes, { prefix: '/internal/sessions' })
|
||||
app.register(mitraActivityRoutes, { prefix: '/internal/mitra-activity' })
|
||||
app.register(failedPairingsRoutes, { prefix: '/internal/failed-pairings' })
|
||||
app.register(internalPaymentCatalogRoutes, { prefix: '/internal' })
|
||||
|
||||
// Dev/test-only — never registered in production builds.
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { mitraStatusRoutes } from './routes/public/mitra.status.routes.js'
|
||||
import { mitraChatRoutes } from './routes/public/mitra.chat.routes.js'
|
||||
import { clientChatRoutes } from './routes/public/client.chat.routes.js'
|
||||
import { clientPaymentRoutes } from './routes/public/client.payment.routes.js'
|
||||
import { clientPaymentMethodsRoutes } from './routes/public/client.payment-methods.routes.js'
|
||||
import { clientMitraAvailabilityRoutes } from './routes/public/client.mitra-availability.routes.js'
|
||||
import { publicBestieAvailabilityRoutes } from './routes/public/public.bestie-availability.routes.js'
|
||||
import { clientOnboardingRoutes } from './routes/public/client.onboarding.routes.js'
|
||||
@@ -37,6 +38,7 @@ export const buildPublicApp = async () => {
|
||||
app.register(mitraChatRoutes, { prefix: '/api/mitra/chat-requests' })
|
||||
app.register(clientChatRoutes, { prefix: '/api/client/chat' })
|
||||
app.register(clientPaymentRoutes, { prefix: '/api/client/payment-requests' })
|
||||
app.register(clientPaymentMethodsRoutes, { prefix: '/api/client/payment-methods' })
|
||||
app.register(clientMitraAvailabilityRoutes, { prefix: '/api/client/mitra-availability' })
|
||||
app.register(publicBestieAvailabilityRoutes, { prefix: '/api/public/bestie' })
|
||||
// Onboarding-state stays client-only (anonymous customer flow). Support
|
||||
|
||||
@@ -1145,6 +1145,118 @@ const migrate = async () => {
|
||||
WHERE payment_request_id IS NOT NULL
|
||||
`
|
||||
|
||||
// --- Phase 5.x: Payment catalog (groups + methods) ----------------------
|
||||
//
|
||||
// DB-backed payment method catalog edited from control center, cached in
|
||||
// Valkey by payment-catalog.service.js. Replaces the hardcoded _PayMethod
|
||||
// enum in client_app/lib/features/payment/screens/payment_method_screen.dart.
|
||||
// See requirement/phase5-payment-catalog-plan.md.
|
||||
//
|
||||
// `payment_code` stores the Xendit channel code (OVO, DANA, QRIS, BCA_VA,
|
||||
// …) verbatim — no mapping layer. UPPER-CASE by convention; the service
|
||||
// layer normalises incoming app-submitted codes to upper before lookup.
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS payment_method_groups (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_method_groups_order
|
||||
ON payment_method_groups (display_order)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS payment_methods (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES payment_method_groups(id) ON DELETE RESTRICT,
|
||||
display_name TEXT NOT NULL,
|
||||
payment_code TEXT NOT NULL UNIQUE,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
icon TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_methods_group_order
|
||||
ON payment_methods (group_id, display_order)
|
||||
`
|
||||
|
||||
// Seed: only when the groups table is empty. Once seeded, operators edit via
|
||||
// CC; we never re-seed (avoid clobbering custom orderings).
|
||||
const [{ n: groupCount }] = await sql`
|
||||
SELECT COUNT(*)::int AS n FROM payment_method_groups
|
||||
`
|
||||
if (groupCount === 0) {
|
||||
const PAYMENT_CATALOG_SEED = [
|
||||
{
|
||||
name: 'Paling Cepat',
|
||||
order: 0,
|
||||
methods: [
|
||||
{ code: 'QRIS', display: 'QRIS', icon: 'qris', active: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E-Wallet',
|
||||
order: 1,
|
||||
methods: [
|
||||
{ code: 'OVO', display: 'OVO', icon: 'ovo', active: true },
|
||||
{ code: 'DANA', display: 'DANA', icon: 'dana', active: true },
|
||||
{ code: 'SHOPEEPAY', display: 'ShopeePay', icon: 'shopeepay', active: true },
|
||||
// Xendit Invoice API doesn't expose GoPay (Gojek/GoPay relationship).
|
||||
// Seeded as inactive so it surfaces in CC but is hidden from the app
|
||||
// until we either confirm a Xendit channel or remove it entirely.
|
||||
{ code: 'GOPAY', display: 'GoPay', icon: 'gopay', active: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Virtual Account',
|
||||
order: 2,
|
||||
methods: [
|
||||
{ code: 'BCA_VA', display: 'BCA Virtual Account', icon: 'bca', active: true },
|
||||
{ code: 'MANDIRI_VA', display: 'Mandiri Virtual Account', icon: 'mandiri', active: true },
|
||||
{ code: 'BNI_VA', display: 'BNI Virtual Account', icon: 'bni', active: true },
|
||||
{ code: 'BRI_VA', display: 'BRI Virtual Account', icon: 'bri', active: true },
|
||||
{ code: 'PERMATA_VA', display: 'Permata Virtual Account', icon: 'permata', active: true },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
for (const group of PAYMENT_CATALOG_SEED) {
|
||||
const [{ id: groupId }] = await sql`
|
||||
INSERT INTO payment_method_groups (name, display_order, is_active)
|
||||
VALUES (${group.name}, ${group.order}, true)
|
||||
RETURNING id
|
||||
`
|
||||
let methodOrder = 0
|
||||
for (const m of group.methods) {
|
||||
await sql`
|
||||
INSERT INTO payment_methods (
|
||||
group_id, display_name, payment_code, display_order, icon, is_active
|
||||
)
|
||||
VALUES (
|
||||
${groupId},
|
||||
${m.display},
|
||||
${m.code},
|
||||
${methodOrder++},
|
||||
${m.icon},
|
||||
${m.active}
|
||||
)
|
||||
ON CONFLICT (payment_code) DO NOTHING
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Migration complete.')
|
||||
await sql.end()
|
||||
}
|
||||
|
||||
254
backend/src/routes/internal/payment-catalog.routes.js
Normal file
254
backend/src/routes/internal/payment-catalog.routes.js
Normal 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 })
|
||||
})
|
||||
}
|
||||
19
backend/src/routes/public/client.payment-methods.routes.js
Normal file
19
backend/src/routes/public/client.payment-methods.routes.js
Normal 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 })
|
||||
})
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
351
backend/src/services/payment-catalog.service.js
Normal file
351
backend/src/services/payment-catalog.service.js
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Payment catalog service — Phase 5.x.
|
||||
*
|
||||
* DB-backed list of payment methods and groups, cached at two levels:
|
||||
* L1: in-process Map (60s TTL) — hot read avoids round-tripping Valkey.
|
||||
* L2: Valkey GET/SETEX (1h TTL) — cross-process source.
|
||||
* L3: Postgres — source of truth.
|
||||
*
|
||||
* Invalidation: any mutator calls invalidatePaymentCatalog(), which:
|
||||
* 1. DEL the Valkey key.
|
||||
* 2. Publish `config:invalidate` with key='payment-catalog'. Other backend
|
||||
* instances drop their L1 entry on receipt (subscriber below).
|
||||
*
|
||||
* App-facing shape (returned by getCatalogForApp): groups pre-filtered to
|
||||
* is_active=true (both group and method), empty groups dropped, ordered by
|
||||
* display_order then method display_order. The customer app renders this
|
||||
* verbatim.
|
||||
*
|
||||
* Control-center shape (returned by listGroups / listMethods): flat lists
|
||||
* with all rows incl. inactive. The CC page filters / edits client-side.
|
||||
*
|
||||
* See requirement/phase5-payment-catalog-plan.md.
|
||||
*/
|
||||
|
||||
import { getDb } from '../db/client.js'
|
||||
import { getValkeyClient, publish, subscribe } from '../plugins/valkey.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
const CACHE_KEY = 'payment-catalog:v1'
|
||||
const VALKEY_TTL_SECONDS = 60 * 60 // 1 hour
|
||||
const INPROCESS_TTL_MS = 60 * 1000 // 60 seconds
|
||||
const INVALIDATE_CHANNEL = 'config:invalidate'
|
||||
const INVALIDATE_KEY = 'payment-catalog'
|
||||
|
||||
// L1 (in-process) cache. One entry — the whole catalog. { value, expiresAt }
|
||||
let l1Entry = null
|
||||
|
||||
const l1Get = () => {
|
||||
if (!l1Entry) return null
|
||||
if (Date.now() > l1Entry.expiresAt) {
|
||||
l1Entry = null
|
||||
return null
|
||||
}
|
||||
return l1Entry.value
|
||||
}
|
||||
|
||||
const l1Set = (value) => {
|
||||
l1Entry = { value, expiresAt: Date.now() + INPROCESS_TTL_MS }
|
||||
}
|
||||
|
||||
const l1Clear = () => {
|
||||
l1Entry = null
|
||||
}
|
||||
|
||||
const valkeyGet = async () => {
|
||||
try {
|
||||
const raw = await getValkeyClient().get(CACHE_KEY)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
} catch (_) {
|
||||
// Valkey down — fall through to DB.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const valkeySet = async (value) => {
|
||||
try {
|
||||
await getValkeyClient().setex(CACHE_KEY, VALKEY_TTL_SECONDS, JSON.stringify(value))
|
||||
} catch (_) {
|
||||
// Best-effort — DB stays source of truth.
|
||||
}
|
||||
}
|
||||
|
||||
const valkeyDel = async () => {
|
||||
try {
|
||||
await getValkeyClient().del(CACHE_KEY)
|
||||
} catch (_) {
|
||||
// Best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full catalog from Postgres in the app-facing shape (active rows
|
||||
* only, empty groups dropped, ordered). Returned object is cached as-is.
|
||||
*/
|
||||
const buildCatalogFromDb = async () => {
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
g.id AS group_id,
|
||||
g.name AS group_name,
|
||||
g.display_order AS group_order,
|
||||
m.id AS method_id,
|
||||
m.payment_code AS payment_code,
|
||||
m.display_name AS display_name,
|
||||
m.icon AS icon,
|
||||
m.display_order AS method_order
|
||||
FROM payment_method_groups g
|
||||
JOIN payment_methods m ON m.group_id = g.id
|
||||
WHERE g.is_active = true AND m.is_active = true
|
||||
ORDER BY g.display_order ASC, m.display_order ASC
|
||||
`
|
||||
|
||||
// Group methods under their groups in the order returned.
|
||||
const byGroupId = new Map()
|
||||
for (const r of rows) {
|
||||
if (!byGroupId.has(r.group_id)) {
|
||||
byGroupId.set(r.group_id, {
|
||||
id: r.group_id,
|
||||
name: r.group_name,
|
||||
order: r.group_order,
|
||||
methods: [],
|
||||
})
|
||||
}
|
||||
byGroupId.get(r.group_id).methods.push({
|
||||
id: r.method_id,
|
||||
payment_code: r.payment_code,
|
||||
display_name: r.display_name,
|
||||
icon: r.icon,
|
||||
order: r.method_order,
|
||||
})
|
||||
}
|
||||
|
||||
// Groups with zero active methods are excluded by the inner JOIN; empty
|
||||
// arrays here are impossible. No extra filter needed.
|
||||
return { groups: Array.from(byGroupId.values()) }
|
||||
}
|
||||
|
||||
/**
|
||||
* App-facing catalog (cached, active-only, grouped). Returns `{ groups: [...] }`.
|
||||
*
|
||||
* Read path: L1 → L2 (Valkey) → DB. Each miss promotes upward.
|
||||
*/
|
||||
export const getCatalogForApp = async () => {
|
||||
const cached = l1Get()
|
||||
if (cached) return cached
|
||||
|
||||
const fromValkey = await valkeyGet()
|
||||
if (fromValkey) {
|
||||
l1Set(fromValkey)
|
||||
return fromValkey
|
||||
}
|
||||
|
||||
const fresh = await buildCatalogFromDb()
|
||||
l1Set(fresh)
|
||||
await valkeySet(fresh)
|
||||
return fresh
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate every layer. Call after any mutation.
|
||||
*/
|
||||
export const invalidatePaymentCatalog = async () => {
|
||||
l1Clear()
|
||||
await valkeyDel()
|
||||
try {
|
||||
await publish(INVALIDATE_CHANNEL, { key: INVALIDATE_KEY, ts: Date.now() })
|
||||
} catch (_) {
|
||||
// Best-effort — other instances will recover via their own L1 TTL.
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-instance L1 invalidation: drop in-process entry when another node
|
||||
// signals a catalog mutation. Idempotent re-subscribe in case the service is
|
||||
// hot-reloaded in dev.
|
||||
let isSubscribed = false
|
||||
const ensureSubscribed = () => {
|
||||
if (isSubscribed) return
|
||||
isSubscribed = true
|
||||
try {
|
||||
subscribe(INVALIDATE_CHANNEL, (msg) => {
|
||||
if (msg?.key === INVALIDATE_KEY) {
|
||||
l1Clear()
|
||||
}
|
||||
})
|
||||
} catch (_) {
|
||||
isSubscribed = false
|
||||
}
|
||||
}
|
||||
ensureSubscribed()
|
||||
|
||||
// --- Control-center read paths (flat, includes inactive) ---------------------
|
||||
|
||||
export const listGroups = async () => {
|
||||
const rows = await sql`
|
||||
SELECT id, name, display_order, is_active, created_at, updated_at
|
||||
FROM payment_method_groups
|
||||
ORDER BY display_order ASC, name ASC
|
||||
`
|
||||
return rows
|
||||
}
|
||||
|
||||
export const listMethods = async ({ groupId = null } = {}) => {
|
||||
const rows = groupId
|
||||
? await sql`
|
||||
SELECT id, group_id, display_name, payment_code, display_order,
|
||||
icon, is_active, created_at, updated_at
|
||||
FROM payment_methods
|
||||
WHERE group_id = ${groupId}
|
||||
ORDER BY display_order ASC, display_name ASC
|
||||
`
|
||||
: await sql`
|
||||
SELECT id, group_id, display_name, payment_code, display_order,
|
||||
icon, is_active, created_at, updated_at
|
||||
FROM payment_methods
|
||||
ORDER BY display_order ASC, display_name ASC
|
||||
`
|
||||
return rows
|
||||
}
|
||||
|
||||
// --- Catalog mutators (used by control-center routes) ------------------------
|
||||
|
||||
export const createGroup = async ({ name, displayOrder = 0, isActive = true }) => {
|
||||
const [row] = await sql`
|
||||
INSERT INTO payment_method_groups (name, display_order, is_active)
|
||||
VALUES (${name}, ${displayOrder}, ${isActive})
|
||||
RETURNING id, name, display_order, is_active
|
||||
`
|
||||
await invalidatePaymentCatalog()
|
||||
return row
|
||||
}
|
||||
|
||||
export const updateGroup = async (id, patch) => {
|
||||
const [row] = await sql`
|
||||
UPDATE payment_method_groups
|
||||
SET
|
||||
name = COALESCE(${patch.name ?? null}, name),
|
||||
display_order = COALESCE(${patch.displayOrder ?? null}, display_order),
|
||||
is_active = COALESCE(${patch.isActive ?? null}, is_active),
|
||||
updated_at = NOW()
|
||||
WHERE id = ${id}
|
||||
RETURNING id, name, display_order, is_active
|
||||
`
|
||||
await invalidatePaymentCatalog()
|
||||
return row
|
||||
}
|
||||
|
||||
export const deleteGroup = async (id) => {
|
||||
// ON DELETE RESTRICT in the schema means this throws if methods still
|
||||
// reference the group. We let the FK error bubble; the route translates it
|
||||
// to a 409 with a clear message.
|
||||
const [row] = await sql`
|
||||
DELETE FROM payment_method_groups
|
||||
WHERE id = ${id}
|
||||
RETURNING id
|
||||
`
|
||||
await invalidatePaymentCatalog()
|
||||
return row
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder groups idempotently. Pass the full ordered array of group IDs;
|
||||
* indexes become the new display_order.
|
||||
*/
|
||||
export const reorderGroups = async (orderedIds) => {
|
||||
await sql.begin(async (tx) => {
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
await tx`
|
||||
UPDATE payment_method_groups
|
||||
SET display_order = ${i}, updated_at = NOW()
|
||||
WHERE id = ${orderedIds[i]}
|
||||
`
|
||||
}
|
||||
})
|
||||
await invalidatePaymentCatalog()
|
||||
}
|
||||
|
||||
export const createMethod = async ({
|
||||
groupId,
|
||||
displayName,
|
||||
paymentCode,
|
||||
displayOrder = 0,
|
||||
icon = null,
|
||||
isActive = true,
|
||||
}) => {
|
||||
const [row] = await sql`
|
||||
INSERT INTO payment_methods (
|
||||
group_id, display_name, payment_code, display_order, icon, is_active
|
||||
)
|
||||
VALUES (
|
||||
${groupId},
|
||||
${displayName},
|
||||
${String(paymentCode).toUpperCase()},
|
||||
${displayOrder},
|
||||
${icon},
|
||||
${isActive}
|
||||
)
|
||||
RETURNING id, group_id, display_name, payment_code, display_order, icon, is_active
|
||||
`
|
||||
await invalidatePaymentCatalog()
|
||||
return row
|
||||
}
|
||||
|
||||
export const updateMethod = async (id, patch) => {
|
||||
const [row] = await sql`
|
||||
UPDATE payment_methods
|
||||
SET
|
||||
group_id = COALESCE(${patch.groupId ?? null}, group_id),
|
||||
display_name = COALESCE(${patch.displayName ?? null}, display_name),
|
||||
payment_code = COALESCE(${patch.paymentCode ? String(patch.paymentCode).toUpperCase() : null}, payment_code),
|
||||
display_order = COALESCE(${patch.displayOrder ?? null}, display_order),
|
||||
icon = COALESCE(${patch.icon ?? null}, icon),
|
||||
is_active = COALESCE(${patch.isActive ?? null}, is_active),
|
||||
updated_at = NOW()
|
||||
WHERE id = ${id}
|
||||
RETURNING id, group_id, display_name, payment_code, display_order, icon, is_active
|
||||
`
|
||||
await invalidatePaymentCatalog()
|
||||
return row
|
||||
}
|
||||
|
||||
export const deleteMethod = async (id) => {
|
||||
const [row] = await sql`
|
||||
DELETE FROM payment_methods
|
||||
WHERE id = ${id}
|
||||
RETURNING id
|
||||
`
|
||||
await invalidatePaymentCatalog()
|
||||
return row
|
||||
}
|
||||
|
||||
export const reorderMethods = async (orderedIds) => {
|
||||
await sql.begin(async (tx) => {
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
await tx`
|
||||
UPDATE payment_methods
|
||||
SET display_order = ${i}, updated_at = NOW()
|
||||
WHERE id = ${orderedIds[i]}
|
||||
`
|
||||
}
|
||||
})
|
||||
await invalidatePaymentCatalog()
|
||||
}
|
||||
|
||||
// --- Validation used by payment.service.js -----------------------------------
|
||||
|
||||
/**
|
||||
* Look up a payment_code in the (cached) catalog. Returns the method row if
|
||||
* found and active, otherwise null. Casing-tolerant — incoming codes from old
|
||||
* app versions sending lower-case (`qris`) are normalised to upper.
|
||||
*/
|
||||
export const findActiveMethodByCode = async (rawCode) => {
|
||||
if (!rawCode) return null
|
||||
const code = String(rawCode).toUpperCase()
|
||||
const { groups } = await getCatalogForApp()
|
||||
for (const g of groups) {
|
||||
for (const m of g.methods) {
|
||||
if (m.payment_code === code) return m
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -176,6 +176,12 @@ export const requestPayment = async ({
|
||||
isFirstSessionDiscount = false,
|
||||
isExtension = false,
|
||||
targetedMitraId = null,
|
||||
// Customer's pre-picked payment method from the catalog. Optional;
|
||||
// upper-cased Xendit channel code (e.g. `OVO`). Stamped onto
|
||||
// product_metadata for analytics + future use as a Xendit `paymentMethods`
|
||||
// filter. Not currently passed to Xendit invoice creation — the customer
|
||||
// re-picks on Xendit's checkout page.
|
||||
preferredPaymentCode = null,
|
||||
}) => {
|
||||
if (!customerId) {
|
||||
throw Object.assign(new Error('customerId is required'), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
||||
@@ -203,9 +209,10 @@ export const requestPayment = async ({
|
||||
is_extension: isExtension,
|
||||
targeted_mitra_id: targetedMitraId,
|
||||
is_first_session_discount: isFirstSessionDiscount,
|
||||
preferred_payment_code: preferredPaymentCode,
|
||||
...productMetadata,
|
||||
}
|
||||
: productMetadata
|
||||
: { preferred_payment_code: preferredPaymentCode, ...productMetadata }
|
||||
|
||||
const [row] = await sql`
|
||||
INSERT INTO payment_requests (
|
||||
|
||||
213
backend/test/services/payment-catalog.service.test.js
Normal file
213
backend/test/services/payment-catalog.service.test.js
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Unit tests for payment-catalog.service.js.
|
||||
*
|
||||
* Covers:
|
||||
* - DB → app-facing shape transformation (grouping, ordering)
|
||||
* - Active-only filter (inactive group OR method excluded; empty groups dropped)
|
||||
* - L1 (in-process) cache hit
|
||||
* - invalidatePaymentCatalog clears the cache
|
||||
* - findActiveMethodByCode is casing-tolerant; returns null for missing/inactive
|
||||
*
|
||||
* We deliberately don't unit-test the Valkey layer here — that's an integration
|
||||
* concern and the real Valkey is wired up in setup.js. The in-process cache is
|
||||
* sufficient to assert that mutators correctly invalidate.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { db } from '../helpers/db.js'
|
||||
|
||||
const {
|
||||
getCatalogForApp,
|
||||
invalidatePaymentCatalog,
|
||||
findActiveMethodByCode,
|
||||
createGroup,
|
||||
updateGroup,
|
||||
createMethod,
|
||||
updateMethod,
|
||||
} = await import('../../src/services/payment-catalog.service.js')
|
||||
|
||||
const sql = db()
|
||||
|
||||
const wipeCatalog = async () => {
|
||||
await sql`DELETE FROM payment_methods`
|
||||
await sql`DELETE FROM payment_method_groups`
|
||||
// Drop any cached state from the previous test.
|
||||
await invalidatePaymentCatalog()
|
||||
}
|
||||
|
||||
const insertGroup = async ({ name, order = 0, active = true }) => {
|
||||
const [row] = await sql`
|
||||
INSERT INTO payment_method_groups (name, display_order, is_active)
|
||||
VALUES (${name}, ${order}, ${active})
|
||||
RETURNING id
|
||||
`
|
||||
return row.id
|
||||
}
|
||||
|
||||
const insertMethod = async ({ groupId, code, display = null, order = 0, icon = null, active = true }) => {
|
||||
const [row] = await sql`
|
||||
INSERT INTO payment_methods (group_id, display_name, payment_code, display_order, icon, is_active)
|
||||
VALUES (${groupId}, ${display ?? code}, ${code}, ${order}, ${icon}, ${active})
|
||||
RETURNING id
|
||||
`
|
||||
return row.id
|
||||
}
|
||||
|
||||
describe('payment-catalog.service', () => {
|
||||
beforeEach(async () => {
|
||||
await wipeCatalog()
|
||||
})
|
||||
|
||||
describe('getCatalogForApp', () => {
|
||||
it('returns { groups: [] } on an empty catalog', async () => {
|
||||
const out = await getCatalogForApp()
|
||||
expect(out).toEqual({ groups: [] })
|
||||
})
|
||||
|
||||
it('groups methods under their group and orders both correctly', async () => {
|
||||
const gWallet = await insertGroup({ name: 'E-Wallet', order: 1 })
|
||||
const gFast = await insertGroup({ name: 'Paling Cepat', order: 0 })
|
||||
|
||||
await insertMethod({ groupId: gWallet, code: 'DANA', order: 1 })
|
||||
await insertMethod({ groupId: gWallet, code: 'OVO', order: 0 })
|
||||
await insertMethod({ groupId: gFast, code: 'QRIS', order: 0 })
|
||||
|
||||
await invalidatePaymentCatalog() // ensure no stale L1 from previous reads
|
||||
const { groups } = await getCatalogForApp()
|
||||
|
||||
expect(groups.map((g) => g.name)).toEqual(['Paling Cepat', 'E-Wallet'])
|
||||
expect(groups[0].methods.map((m) => m.payment_code)).toEqual(['QRIS'])
|
||||
expect(groups[1].methods.map((m) => m.payment_code)).toEqual(['OVO', 'DANA'])
|
||||
})
|
||||
|
||||
it('drops inactive methods', async () => {
|
||||
const g = await insertGroup({ name: 'E-Wallet' })
|
||||
await insertMethod({ groupId: g, code: 'OVO', active: true })
|
||||
await insertMethod({ groupId: g, code: 'GOPAY', active: false })
|
||||
|
||||
await invalidatePaymentCatalog()
|
||||
const { groups } = await getCatalogForApp()
|
||||
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0].methods.map((m) => m.payment_code)).toEqual(['OVO'])
|
||||
})
|
||||
|
||||
it('drops inactive groups (and their methods, even if active)', async () => {
|
||||
const gActive = await insertGroup({ name: 'E-Wallet', order: 0, active: true })
|
||||
const gHidden = await insertGroup({ name: 'Hidden', order: 1, active: false })
|
||||
await insertMethod({ groupId: gActive, code: 'OVO' })
|
||||
await insertMethod({ groupId: gHidden, code: 'X1' })
|
||||
|
||||
await invalidatePaymentCatalog()
|
||||
const { groups } = await getCatalogForApp()
|
||||
|
||||
expect(groups.map((g) => g.name)).toEqual(['E-Wallet'])
|
||||
})
|
||||
|
||||
it('drops groups with no active methods', async () => {
|
||||
const gEmpty = await insertGroup({ name: 'Empty', order: 0 })
|
||||
const gFull = await insertGroup({ name: 'E-Wallet', order: 1 })
|
||||
await insertMethod({ groupId: gEmpty, code: 'GHOST', active: false })
|
||||
await insertMethod({ groupId: gFull, code: 'OVO' })
|
||||
|
||||
await invalidatePaymentCatalog()
|
||||
const { groups } = await getCatalogForApp()
|
||||
|
||||
expect(groups.map((g) => g.name)).toEqual(['E-Wallet'])
|
||||
})
|
||||
|
||||
it('caches in-process (second call returns the same object reference)', async () => {
|
||||
const g = await insertGroup({ name: 'E-Wallet' })
|
||||
await insertMethod({ groupId: g, code: 'OVO' })
|
||||
|
||||
await invalidatePaymentCatalog()
|
||||
const a = await getCatalogForApp()
|
||||
const b = await getCatalogForApp()
|
||||
expect(b).toBe(a) // same object identity = served from L1
|
||||
})
|
||||
|
||||
it('a mutator invalidates the cache', async () => {
|
||||
const g = await insertGroup({ name: 'E-Wallet' })
|
||||
await insertMethod({ groupId: g, code: 'OVO' })
|
||||
|
||||
await invalidatePaymentCatalog()
|
||||
const first = await getCatalogForApp()
|
||||
expect(first.groups[0].methods).toHaveLength(1)
|
||||
|
||||
// Add a new method via the service mutator.
|
||||
await createMethod({ groupId: g, displayName: 'DANA', paymentCode: 'DANA' })
|
||||
|
||||
const second = await getCatalogForApp()
|
||||
expect(second).not.toBe(first) // L1 was cleared, fresh read
|
||||
expect(second.groups[0].methods).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findActiveMethodByCode', () => {
|
||||
beforeEach(async () => {
|
||||
const g = await insertGroup({ name: 'E-Wallet' })
|
||||
await insertMethod({ groupId: g, code: 'OVO' })
|
||||
await insertMethod({ groupId: g, code: 'GOPAY', active: false })
|
||||
await invalidatePaymentCatalog()
|
||||
})
|
||||
|
||||
it('matches by exact code', async () => {
|
||||
const m = await findActiveMethodByCode('OVO')
|
||||
expect(m?.payment_code).toBe('OVO')
|
||||
})
|
||||
|
||||
it('is casing-tolerant (lower-case incoming)', async () => {
|
||||
const m = await findActiveMethodByCode('ovo')
|
||||
expect(m?.payment_code).toBe('OVO')
|
||||
})
|
||||
|
||||
it('returns null for an inactive code', async () => {
|
||||
const m = await findActiveMethodByCode('GOPAY')
|
||||
expect(m).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for an unknown code', async () => {
|
||||
const m = await findActiveMethodByCode('UNKNOWN_CODE')
|
||||
expect(m).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty / undefined input', async () => {
|
||||
expect(await findActiveMethodByCode('')).toBeNull()
|
||||
expect(await findActiveMethodByCode(undefined)).toBeNull()
|
||||
expect(await findActiveMethodByCode(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mutator side effects', () => {
|
||||
it('createGroup persists + uppercases nothing (group names are free-form)', async () => {
|
||||
const row = await createGroup({ name: 'Cards', displayOrder: 5 })
|
||||
expect(row.name).toBe('Cards')
|
||||
expect(row.display_order).toBe(5)
|
||||
expect(row.is_active).toBe(true)
|
||||
})
|
||||
|
||||
it('createMethod uppercases payment_code', async () => {
|
||||
const g = await createGroup({ name: 'Cards' })
|
||||
const m = await createMethod({
|
||||
groupId: g.id,
|
||||
displayName: 'Visa',
|
||||
paymentCode: 'visa', // lowercase incoming
|
||||
})
|
||||
expect(m.payment_code).toBe('VISA')
|
||||
})
|
||||
|
||||
it('updateMethod also uppercases payment_code when patched', async () => {
|
||||
const g = await createGroup({ name: 'Cards' })
|
||||
const m = await createMethod({ groupId: g.id, displayName: 'Visa', paymentCode: 'VISA' })
|
||||
const updated = await updateMethod(m.id, { paymentCode: 'mastercard' })
|
||||
expect(updated.payment_code).toBe('MASTERCARD')
|
||||
})
|
||||
|
||||
it('updateGroup applies COALESCE patches (omitted fields preserved)', async () => {
|
||||
const g = await createGroup({ name: 'Cards', displayOrder: 1 })
|
||||
const out = await updateGroup(g.id, { name: 'Credit Cards' })
|
||||
expect(out.name).toBe('Credit Cards')
|
||||
expect(out.display_order).toBe(1) // unchanged
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user