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:
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