diff --git a/backend/src/app.internal.js b/backend/src/app.internal.js index aae5bfe..117bdbe 100644 --- a/backend/src/app.internal.js +++ b/backend/src/app.internal.js @@ -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') { diff --git a/backend/src/app.public.js b/backend/src/app.public.js index c0b336a..d924e20 100644 --- a/backend/src/app.public.js +++ b/backend/src/app.public.js @@ -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 diff --git a/backend/src/db/migrate.js b/backend/src/db/migrate.js index 9a4965e..dd59ae4 100644 --- a/backend/src/db/migrate.js +++ b/backend/src/db/migrate.js @@ -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() } diff --git a/backend/src/routes/internal/payment-catalog.routes.js b/backend/src/routes/internal/payment-catalog.routes.js new file mode 100644 index 0000000..1ff790e --- /dev/null +++ b/backend/src/routes/internal/payment-catalog.routes.js @@ -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 }) + }) +} diff --git a/backend/src/routes/public/client.payment-methods.routes.js b/backend/src/routes/public/client.payment-methods.routes.js new file mode 100644 index 0000000..3f4c26c --- /dev/null +++ b/backend/src/routes/public/client.payment-methods.routes.js @@ -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 }) + }) +} diff --git a/backend/src/routes/public/client.payment.routes.js b/backend/src/routes/public/client.payment.routes.js index f871bcf..303e270 100644 --- a/backend/src/routes/public/client.payment.routes.js +++ b/backend/src/routes/public/client.payment.routes.js @@ -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({ diff --git a/backend/src/services/payment-catalog.service.js b/backend/src/services/payment-catalog.service.js new file mode 100644 index 0000000..0b4c7b0 --- /dev/null +++ b/backend/src/services/payment-catalog.service.js @@ -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 +} diff --git a/backend/src/services/payment.service.js b/backend/src/services/payment.service.js index c79738e..5630b32 100644 --- a/backend/src/services/payment.service.js +++ b/backend/src/services/payment.service.js @@ -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 ( diff --git a/backend/test/services/payment-catalog.service.test.js b/backend/test/services/payment-catalog.service.test.js new file mode 100644 index 0000000..2df9814 --- /dev/null +++ b/backend/test/services/payment-catalog.service.test.js @@ -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 + }) + }) +}) diff --git a/client_app/assets/images/splash/splash_1.png b/client_app/assets/images/splash/splash_1.png deleted file mode 100644 index 29c303d..0000000 Binary files a/client_app/assets/images/splash/splash_1.png and /dev/null differ diff --git a/client_app/assets/images/splash/splash_2.png b/client_app/assets/images/splash/splash_2.png deleted file mode 100644 index 2bd9d7b..0000000 Binary files a/client_app/assets/images/splash/splash_2.png and /dev/null differ diff --git a/client_app/assets/images/splash/splash_3.png b/client_app/assets/images/splash/splash_3.png deleted file mode 100644 index 412c4cd..0000000 Binary files a/client_app/assets/images/splash/splash_3.png and /dev/null differ diff --git a/client_app/assets/images/splash_chat_hebat.png b/client_app/assets/images/splash_chat_hebat.png deleted file mode 100644 index d3034f7..0000000 Binary files a/client_app/assets/images/splash_chat_hebat.png and /dev/null differ diff --git a/client_app/assets/payment_icons/NOTICE_IDN_FINLOGOS.txt b/client_app/assets/payment_icons/NOTICE_IDN_FINLOGOS.txt new file mode 100644 index 0000000..a64be70 --- /dev/null +++ b/client_app/assets/payment_icons/NOTICE_IDN_FINLOGOS.txt @@ -0,0 +1,31 @@ +Logo Assets License — Creative Commons Attribution-NonCommercial 4.0 International +(CC BY-NC 4.0) + +This license applies to all SVG logo assets in `icons/` and `dist/icons/`. + +Copyright (c) 2026 Hafidz Noor Fauzi (collection/curation only). + +The underlying logo marks remain the property of their respective trademark +holders. See NOTICE for details on trademark ownership and disclaimers. + +You are free to: + - Share — copy and redistribute the material in any medium or format. + - Adapt — remix, transform, and build upon the material. + +Under the following terms: + - Attribution — You must give appropriate credit, provide a link to this + license, and indicate if changes were made. You may do so in any + reasonable manner, but not in any way that suggests the licensor + endorses you or your use. + - NonCommercial — You may not use the material for commercial purposes. + +No additional restrictions — You may not apply legal terms or technological +measures that legally restrict others from doing anything the license permits. + +Full license text: + https://creativecommons.org/licenses/by-nc/4.0/legalcode + +Human-readable summary: + https://creativecommons.org/licenses/by-nc/4.0/ + +THE LOGO ASSETS ARE PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. diff --git a/client_app/assets/payment_icons/README.md b/client_app/assets/payment_icons/README.md new file mode 100644 index 0000000..368da09 --- /dev/null +++ b/client_app/assets/payment_icons/README.md @@ -0,0 +1,79 @@ +# Payment method icons + +Bundled SVGs referenced by the payment catalog (`payment_methods.icon` slug +in the backend). The customer app's `PaymentIcon` widget resolves +`.svg` from this directory and falls back to `placeholder.svg` when the +file isn't bundled — see `client_app/lib/features/payment/widgets/payment_icon.dart`. + +## Naming convention + +One SVG per slug, lower-case, hyphens not underscores. Currently bundled +slugs (match the migrate.js seed): + +| Slug | Method | Source filename in `idn-finlogos/icons/` | +|---|---|---| +| `qris` | QRIS | `qris.svg` | +| `ovo` | OVO | `ovo-new.svg` | +| `dana` | DANA | `dana.svg` | +| `shopeepay` | ShopeePay | `shopee-pay.svg` | +| `gopay` | GoPay (seeded inactive) | `gopay.svg` | +| `bca` | BCA Virtual Account | `bca.svg` | +| `mandiri` | Mandiri Virtual Account | `mandiri.svg` | +| `bni` | BNI Virtual Account | `bni.svg` | +| `bri` | BRI Virtual Account | `bri.svg` | +| `permata` | Permata Virtual Account | `permata.svg` | + +## Sourcing — idn-finlogos + +We pull individual SVGs from +[github.com/hafidznoor/idn-finlogos](https://github.com/hafidznoor/idn-finlogos) +rather than installing the Flutter package (`idn_finlogos` on pub.dev). The +package bundles all 572 SVGs as Flutter assets — Flutter doesn't tree-shake +assets, so adding the package ships ~4-6 MB of marks we never use. Manual +copy keeps the APK lean: 10 marks ≈ 80 KB on-disk. + +See `NOTICE_IDN_FINLOGOS.txt` for the upstream licensing terms (MIT for build +tooling, CC BY-NC 4.0 for the SVG assets, plus the project's note that +individual brand marks require permission from each brand holder for +commercial use). + +## Adding a new method + +1. **Catalog row** (control center → Payment Catalog): + - Add a method with `payment_code` set to the Xendit channel code + (uppercase, e.g. `LINKAJA`) and `icon` set to the desired slug + (lowercase, e.g. `linkaja`). + - Method renders immediately with the generic placeholder icon. +2. **Branded SVG** (one-time, requires an app release): + ```sh + cd client_app/assets/payment_icons + curl -sS https://raw.githubusercontent.com/hafidznoor/idn-finlogos/main/icons/.svg -o .svg + ``` + (Browse the repo's `icons/` directory if the source filename isn't an + exact match — e.g. `ovo-new.svg` for the modern OVO mark.) +3. **Append the slug** to `_kBundledSlugs` in + `client_app/lib/features/payment/widgets/payment_icon.dart` so the widget + stops falling back to the placeholder for that slug. +4. **Cut a release.** Assets ship with the APK; new icons need a binary update. + +## Why the explicit `_kBundledSlugs` list? + +Flutter's asset system throws if a referenced asset is missing — there's no +"file exists?" check at runtime without round-tripping the AssetManifest. +Keeping the bundled-slugs list in code makes "what we ship" explicit and +keeps the icon widget cheap. + +## When to migrate to the pub package + +The manual-copy approach beats `idn_finlogos` as long as we bundle fewer +than ~50 icons (where the ~4-6 MB whole-library payload starts looking +reasonable compared to per-file curl-and-commit overhead). If we cross that +threshold, switch: + +```yaml +dependencies: + idn_finlogos: ^2.3.0 +``` + +…and delete this directory's per-icon SVGs (keep `placeholder.svg` + the +slug allowlist). diff --git a/client_app/assets/payment_icons/bca.svg b/client_app/assets/payment_icons/bca.svg new file mode 100644 index 0000000..4ddf06e --- /dev/null +++ b/client_app/assets/payment_icons/bca.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client_app/assets/payment_icons/bni.svg b/client_app/assets/payment_icons/bni.svg new file mode 100644 index 0000000..ac4ed9a --- /dev/null +++ b/client_app/assets/payment_icons/bni.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/client_app/assets/payment_icons/bri.svg b/client_app/assets/payment_icons/bri.svg new file mode 100644 index 0000000..54e712c --- /dev/null +++ b/client_app/assets/payment_icons/bri.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/client_app/assets/payment_icons/dana.svg b/client_app/assets/payment_icons/dana.svg new file mode 100644 index 0000000..c1a9052 --- /dev/null +++ b/client_app/assets/payment_icons/dana.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/client_app/assets/payment_icons/gopay.svg b/client_app/assets/payment_icons/gopay.svg new file mode 100644 index 0000000..c90a98b --- /dev/null +++ b/client_app/assets/payment_icons/gopay.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/client_app/assets/payment_icons/mandiri.svg b/client_app/assets/payment_icons/mandiri.svg new file mode 100644 index 0000000..f0f5e85 --- /dev/null +++ b/client_app/assets/payment_icons/mandiri.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client_app/assets/payment_icons/ovo.svg b/client_app/assets/payment_icons/ovo.svg new file mode 100644 index 0000000..38d40af --- /dev/null +++ b/client_app/assets/payment_icons/ovo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/client_app/assets/payment_icons/permata.svg b/client_app/assets/payment_icons/permata.svg new file mode 100644 index 0000000..f984955 --- /dev/null +++ b/client_app/assets/payment_icons/permata.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client_app/assets/payment_icons/placeholder.svg b/client_app/assets/payment_icons/placeholder.svg new file mode 100644 index 0000000..521be9b --- /dev/null +++ b/client_app/assets/payment_icons/placeholder.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/client_app/assets/payment_icons/qris.svg b/client_app/assets/payment_icons/qris.svg new file mode 100644 index 0000000..26f8b52 --- /dev/null +++ b/client_app/assets/payment_icons/qris.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/client_app/assets/payment_icons/shopeepay.svg b/client_app/assets/payment_icons/shopeepay.svg new file mode 100644 index 0000000..cc6ce93 --- /dev/null +++ b/client_app/assets/payment_icons/shopeepay.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/client_app/lib/features/auth/screens/register_screen.dart b/client_app/lib/features/auth/screens/register_screen.dart index 886ca2d..0c42348 100644 --- a/client_app/lib/features/auth/screens/register_screen.dart +++ b/client_app/lib/features/auth/screens/register_screen.dart @@ -11,7 +11,6 @@ import '../../../core/theme/widgets/widgets.dart'; /// S3a — WhatsApp input screen. /// /// Visual contract is `Figma/screens/onboarding.jsx::S3Phone`: -/// - HaloStepDots at the top (step 3 of 4: S2 Nama → S5 ESP → S5b USP → S3a) /// - Personalised display-title `"nomor wa-mu, {name}?"` /// - +62 prefix as static chip; user types only the trailing digits /// - Privacy reassurance card @@ -137,9 +136,21 @@ class _RegisterScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Padding( - padding: EdgeInsets.only(top: 8, bottom: 8), - child: HaloStepDots(total: 4, current: 3), + Padding( + padding: const EdgeInsets.only(top: 4, bottom: 4), + child: Row( + children: [ + _CircleBackButton( + onTap: () { + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } else { + context.go('/home'); + } + }, + ), + ], + ), ), Expanded( child: LayoutBuilder( @@ -152,6 +163,8 @@ class _RegisterScreenState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + const Center(child: _LogoBadge()), + const SizedBox(height: 18), Text( 'nomor wa-mu, $shownName?', style: const TextStyle( @@ -312,6 +325,58 @@ class _PhoneRow extends StatelessWidget { } } +class _CircleBackButton extends StatelessWidget { + final VoidCallback onTap; + const _CircleBackButton({required this.onTap}); + + @override + Widget build(BuildContext context) { + return Material( + color: HaloTokens.brand, + shape: const CircleBorder(), + elevation: 2, + shadowColor: const Color(0x1F000000), + child: InkWell( + customBorder: const CircleBorder(), + onTap: onTap, + child: const SizedBox( + width: 40, + height: 40, + child: Icon(Icons.arrow_back, color: Colors.white, size: 20), + ), + ), + ); + } +} + +class _LogoBadge extends StatelessWidget { + const _LogoBadge(); + + @override + Widget build(BuildContext context) { + return Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: HaloTokens.brandLogoBg, + borderRadius: BorderRadius.circular(20), + boxShadow: const [ + BoxShadow( + color: Color(0x47FF69A0), + blurRadius: 18, + offset: Offset(0, 6), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: Transform.scale( + scale: 1.4, + child: Image.asset('assets/icons/logo.png', fit: BoxFit.cover), + ), + ); + } +} + class _PrivacyCard extends StatelessWidget { const _PrivacyCard(); diff --git a/client_app/lib/features/onboarding/onboarding_screen.dart b/client_app/lib/features/onboarding/onboarding_screen.dart deleted file mode 100644 index 8d790fa..0000000 --- a/client_app/lib/features/onboarding/onboarding_screen.dart +++ /dev/null @@ -1,210 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import '../../router.dart'; - -const _kOnboardingDone = 'onboarding_done'; -const _kPink = Color(0xFFBE7C8A); - -class _OnboardingPage { - final String title; - final String text; - final String image; - - const _OnboardingPage({ - required this.title, - required this.text, - required this.image, - }); -} - -const _pages = [ - _OnboardingPage( - title: 'Langsung Curhat', - text: 'Tidak perlu form panjang atau janji. Masuk dan langsung ngobrol.', - image: 'assets/images/splash/splash_1.png', - ), - _OnboardingPage( - title: '100% Anonim', - text: 'Identitas kamu tidak akan ditampilkan. Cerita dengan tenang, tanpa khawatir.', - image: 'assets/images/splash/splash_2.png', - ), - _OnboardingPage( - title: 'Bestie yang Relevan', - text: 'Kamu akan dipasangkan dengan bestie berdasarkan topik & kondisi kamu saat ini.', - image: 'assets/images/splash/splash_3.png', - ), -]; - -class OnboardingScreen extends ConsumerStatefulWidget { - const OnboardingScreen({super.key}); - - @override - ConsumerState createState() => _OnboardingScreenState(); -} - -class _OnboardingScreenState extends ConsumerState { - final _controller = PageController(); - int _currentPage = 0; - - @override - void initState() { - super.initState(); - // Auto-advance: page 0 → 1 after 500ms - _scheduleAutoAdvance(0); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - void _scheduleAutoAdvance(int fromPage) { - // Only auto-advance for pages 0 and 1 - if (fromPage >= 2) return; - Future.delayed(const Duration(seconds: 1), () { - if (mounted && _currentPage == fromPage) { - _controller.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - } - }); - } - - void _onPageChanged(int index) { - setState(() => _currentPage = index); - _scheduleAutoAdvance(index); - } - - Future _finish() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(_kOnboardingDone, true); - ref.invalidate(onboardingDoneProvider); - if (mounted) { - context.go('/home'); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.white, - body: SafeArea( - child: Column( - children: [ - Expanded( - child: PageView.builder( - controller: _controller, - itemCount: _pages.length, - onPageChanged: _onPageChanged, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) { - final page = _pages[index]; - return _buildPage(page); - }, - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), - child: Row( - children: [ - // Page indicators - Row( - children: List.generate(_pages.length, (index) { - final isActive = index == _currentPage; - return Container( - margin: const EdgeInsets.only(right: 8), - width: isActive ? 32 : 12, - height: 6, - decoration: BoxDecoration( - color: isActive ? _kPink : _kPink.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(3), - ), - ); - }), - ), - const Spacer(), - // CTA button — only show "Mulai" on last page - if (_currentPage == _pages.length - 1) - GestureDetector( - onTap: _finish, - child: Container( - height: 56, - padding: const EdgeInsets.symmetric(horizontal: 32), - decoration: BoxDecoration( - color: _kPink, - borderRadius: BorderRadius.circular(16), - ), - child: const Center( - child: Text( - 'Mulai', - style: TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _buildPage(_OnboardingPage page) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - children: [ - const Spacer(flex: 1), - // Image - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Image.asset( - page.image, - height: 280, - fit: BoxFit.contain, - ), - ), - const Spacer(flex: 1), - // Title - Text( - page.title, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: _kPink, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - // Description - Text( - page.text, - style: TextStyle( - fontSize: 16, - color: Colors.pink.shade300, - height: 1.5, - ), - textAlign: TextAlign.center, - ), - const Spacer(flex: 1), - ], - ), - ); - } -} - -/// Check if onboarding has been completed -Future isOnboardingDone() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getBool(_kOnboardingDone) ?? false; -} diff --git a/client_app/lib/features/payment/screens/payment_method_screen.dart b/client_app/lib/features/payment/screens/payment_method_screen.dart index 2925b52..376ec56 100644 --- a/client_app/lib/features/payment/screens/payment_method_screen.dart +++ b/client_app/lib/features/payment/screens/payment_method_screen.dart @@ -6,11 +6,19 @@ import '../../../core/api/api_client_provider.dart'; import '../../../core/constants.dart'; import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/widgets/halo_button.dart'; +import '../state/payment_catalog_provider.dart'; import '../state/payment_draft_provider.dart'; +import '../widgets/payment_icon.dart'; -/// "Cara bayar" — QRIS-first list of payment methods. On tap of `bayar`: -/// 1. POST `/api/client/payment-requests` with the draft + chosen method. -/// 2. Push `/payment/waiting/:paymentId`. +/// "Cara bayar" — catalog-driven payment method picker. Methods are grouped +/// into collapsible sections sourced from `paymentCatalogProvider`; the user +/// picks one, then taps `bayar` which: +/// 1. POSTs `/api/client/payment-requests` with the draft + chosen +/// `payment_code`. +/// 2. Pushes `/payment/waiting/:paymentId`. +/// +/// First group is expanded by default; the rest are collapsed. Empty groups +/// are hidden by the backend before they reach this screen. class PaymentMethodScreen extends ConsumerStatefulWidget { const PaymentMethodScreen({super.key}); @@ -18,33 +26,17 @@ class PaymentMethodScreen extends ConsumerStatefulWidget { ConsumerState createState() => _PaymentMethodScreenState(); } -enum _PayMethod { - qris('qris', 'QRIS', 'semua e-wallet & m-banking', '🔲', recommended: true), - ovo('ovo', 'OVO', 'saldo OVO', '🟣'), - gopay('gopay', 'GoPay', 'saldo GoPay', '🟢'), - dana('dana', 'DANA', 'saldo DANA', '🔵'), - shopee('shopee', 'ShopeePay', 'saldo ShopeePay', '🟠'); - - final String id; - final String label; - final String sub; - final String icon; - final bool recommended; - - const _PayMethod( - this.id, - this.label, - this.sub, - this.icon, { - this.recommended = false, - }); -} - class _PaymentMethodScreenState extends ConsumerState { - _PayMethod _selected = _PayMethod.qris; + String? _selectedCode; bool _submitting = false; String? _error; + /// Track which groups are expanded. Keyed by group id; default-expanded for + /// the first group is applied lazily inside `build` when we first see the + /// catalog (we don't know the group ids until then). + final Set _expandedGroupIds = {}; + bool _initialExpansionDone = false; + Future _onPay() async { if (_submitting) return; final draft = ref.read(paymentDraftNotifierProvider); @@ -52,6 +44,10 @@ class _PaymentMethodScreenState extends ConsumerState { setState(() => _error = 'Pilih durasi dulu sebelum bayar.'); return; } + if (_selectedCode == null) { + setState(() => _error = 'Pilih metode pembayaran dulu.'); + return; + } setState(() { _submitting = true; _error = null; @@ -63,15 +59,9 @@ class _PaymentMethodScreenState extends ConsumerState { 'duration_minutes': draft.durationMinutes, 'price_idr': draft.priceIDR, 'is_first_session_discount': draft.isFirstSessionDiscount, - 'method': _selected.id, - // Returning-targeted "Curhat lagi" flow: backend ties the payment - // session to the picked mitra so the eventual chat request can fire - // against the same bestie. Absent on the general-blast path. - if (draft.targetedMitraId != null) - 'targeted_mitra_id': draft.targetedMitraId, + 'method': _selectedCode, + if (draft.targetedMitraId != null) 'targeted_mitra_id': draft.targetedMitraId, }; - // Trailing slash matches the existing payment_notifier path — Fastify - // is not configured with `ignoreTrailingSlash`. final response = await api.post('/api/client/payment-requests/', data: body); final data = response['data'] as Map; final paymentId = data['id'] as String; @@ -99,21 +89,29 @@ class _PaymentMethodScreenState extends ConsumerState { if (status == 422 || code == 'VALIDATION_ERROR' || code == 'INVALID_TIER') { return 'Pilihan durasi tidak valid.'; } + if (code == 'INVALID_PAYMENT_METHOD') { + return 'Metode pembayaran tidak tersedia.'; + } if (status == 403) return 'Sesi tidak diizinkan.'; if (status == 404) return 'Sesi pembayaran tidak ditemukan.'; return 'Gagal membuat sesi pembayaran.'; } + void _applyInitialExpansion(PaymentCatalog catalog) { + if (_initialExpansionDone || catalog.groups.isEmpty) return; + _expandedGroupIds.add(catalog.groups.first.id); + _initialExpansionDone = true; + } + @override Widget build(BuildContext context) { final draft = ref.watch(paymentDraftNotifierProvider); + final catalogAsync = ref.watch(paymentCatalogProvider); final amount = draft.priceIDR ?? 0; final durationLabel = draft.durationMinutes != null ? 'sesi ${draft.durationMinutes} menit' : 'sesi'; final amountLabel = formatRupiah(amount); - final recommended = _PayMethod.values.where((m) => m.recommended).toList(); - final others = _PayMethod.values.where((m) => !m.recommended).toList(); return Scaffold( backgroundColor: HaloTokens.bg, @@ -143,6 +141,7 @@ class _PaymentMethodScreenState extends ConsumerState { ), body: Column( children: [ + // Amount summary card (unchanged from the pre-catalog version). Padding( padding: const EdgeInsets.fromLTRB( HaloSpacing.s24, @@ -188,30 +187,39 @@ class _PaymentMethodScreenState extends ConsumerState { ), ), ), + // Catalog body — collapsible groups. Expanded( - child: ListView( - padding: const EdgeInsets.fromLTRB( - HaloSpacing.s20, - HaloSpacing.s8, - HaloSpacing.s20, - HaloSpacing.s16, - ), - children: [ - const _SectionLabel('paling cepat'), - ...recommended.map((m) => _MethodTile( - method: m, - selected: _selected == m, - onTap: () => setState(() => _selected = m), - large: true, - )), - const SizedBox(height: HaloSpacing.s8), - const _SectionLabel('e-wallet lain'), - ...others.map((m) => _MethodTile( - method: m, - selected: _selected == m, - onTap: () => setState(() => _selected = m), - )), - ], + child: catalogAsync.when( + data: (catalog) { + _applyInitialExpansion(catalog); + return ListView( + padding: const EdgeInsets.fromLTRB( + HaloSpacing.s20, + HaloSpacing.s8, + HaloSpacing.s20, + HaloSpacing.s16, + ), + children: catalog.groups.map((g) { + return _GroupSection( + group: g, + expanded: _expandedGroupIds.contains(g.id), + selectedCode: _selectedCode, + onToggle: () => setState(() { + if (!_expandedGroupIds.add(g.id)) { + _expandedGroupIds.remove(g.id); + } + }), + onSelect: (code) => setState(() { + _selectedCode = code; + _error = null; + }), + ); + }).toList(), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => + const Center(child: Text('Gagal memuat metode pembayaran.')), ), ), if (_error != null) @@ -256,7 +264,7 @@ class _PaymentMethodScreenState extends ConsumerState { label: _submitting ? 'memproses...' : 'bayar $amountLabel', size: HaloButtonSize.lg, fullWidth: true, - onPressed: _submitting ? null : _onPay, + onPressed: (_submitting || _selectedCode == null) ? null : _onPay, ), ), ], @@ -265,38 +273,93 @@ class _PaymentMethodScreenState extends ConsumerState { } } -class _SectionLabel extends StatelessWidget { - final String label; - const _SectionLabel(this.label); +class _GroupSection extends StatelessWidget { + final PaymentMethodGroup group; + final bool expanded; + final String? selectedCode; + final VoidCallback onToggle; + final ValueChanged onSelect; + + const _GroupSection({ + required this.group, + required this.expanded, + required this.selectedCode, + required this.onToggle, + required this.onSelect, + }); @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.fromLTRB(4, HaloSpacing.s8, 4, HaloSpacing.s8), - child: Text( - label, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: HaloTokens.inkSoft, - letterSpacing: 0.6, - ), + padding: const EdgeInsets.only(bottom: HaloSpacing.s12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InkWell( + onTap: onToggle, + borderRadius: HaloRadius.md, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: HaloSpacing.s8, + horizontal: HaloSpacing.s4, + ), + child: Row( + children: [ + Expanded( + child: Text( + group.name.toLowerCase(), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: HaloTokens.brandDark, + letterSpacing: 0.4, + ), + ), + ), + AnimatedRotation( + duration: HaloMotion.fast, + turns: expanded ? 0.5 : 0, + child: const Icon( + Icons.keyboard_arrow_down, + color: HaloTokens.brandDark, + size: 20, + ), + ), + ], + ), + ), + ), + AnimatedCrossFade( + duration: HaloMotion.normal, + firstChild: const SizedBox(height: 0), + secondChild: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: group.methods + .map((m) => _MethodTile( + method: m, + selected: selectedCode == m.paymentCode, + onTap: () => onSelect(m.paymentCode), + )) + .toList(), + ), + crossFadeState: + expanded ? CrossFadeState.showSecond : CrossFadeState.showFirst, + ), + ], ), ); } } class _MethodTile extends StatelessWidget { - final _PayMethod method; + final PaymentMethodEntry method; final bool selected; final VoidCallback onTap; - final bool large; const _MethodTile({ required this.method, required this.selected, required this.onTap, - this.large = false, }); @override @@ -311,7 +374,7 @@ class _MethodTile extends StatelessWidget { onTap: onTap, child: AnimatedContainer( duration: HaloMotion.fast, - padding: EdgeInsets.all(large ? HaloSpacing.s16 : HaloSpacing.s12), + padding: const EdgeInsets.all(HaloSpacing.s12), decoration: BoxDecoration( border: Border.all( color: selected ? HaloTokens.brand : HaloTokens.border, @@ -322,69 +385,34 @@ class _MethodTile extends StatelessWidget { child: Row( children: [ Container( - width: large ? 40 : 36, - height: large ? 40 : 36, + width: 40, + height: 40, decoration: BoxDecoration( color: HaloTokens.surface, borderRadius: HaloRadius.md, border: Border.all(color: HaloTokens.border), ), alignment: Alignment.center, - child: Text(method.icon, style: TextStyle(fontSize: large ? 20 : 18)), + child: PaymentIcon( + slug: method.icon, + size: 22, + color: HaloTokens.brandDark, + ), ), const SizedBox(width: HaloSpacing.s12), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - method.label, - style: TextStyle( - fontSize: large ? 14.5 : 13.5, - fontWeight: FontWeight.w600, - color: HaloTokens.ink, - ), - ), - if (method.recommended) ...[ - const SizedBox(width: HaloSpacing.s8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: HaloSpacing.s8, - vertical: 2, - ), - decoration: const BoxDecoration( - color: HaloTokens.mint, - borderRadius: HaloRadius.pill, - ), - child: const Text( - 'DIREKOMENDASIKAN', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w700, - color: Color(0xFF1F4D34), - letterSpacing: 0.4, - ), - ), - ), - ], - ], - ), - const SizedBox(height: 2), - Text( - method.sub, - style: TextStyle( - fontSize: large ? 11.5 : 11, - color: HaloTokens.inkSoft, - ), - ), - ], + child: Text( + method.displayName, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: HaloTokens.ink, + ), ), ), Container( - width: large ? 20 : 18, - height: large ? 20 : 18, + width: 20, + height: 20, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( @@ -396,8 +424,8 @@ class _MethodTile extends StatelessWidget { child: selected ? Center( child: Container( - width: large ? 8 : 6, - height: large ? 8 : 6, + width: 8, + height: 8, decoration: const BoxDecoration( shape: BoxShape.circle, color: Colors.white, diff --git a/client_app/lib/features/payment/state/payment_catalog_provider.dart b/client_app/lib/features/payment/state/payment_catalog_provider.dart new file mode 100644 index 0000000..4164862 --- /dev/null +++ b/client_app/lib/features/payment/state/payment_catalog_provider.dart @@ -0,0 +1,99 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/api/api_client_provider.dart'; + +/// One row in the payment-method catalog (server-side: `payment_methods`). +class PaymentMethodEntry { + final String id; + final String paymentCode; + final String displayName; + final String? icon; + + const PaymentMethodEntry({ + required this.id, + required this.paymentCode, + required this.displayName, + this.icon, + }); +} + +/// One group in the payment-method catalog (server-side: +/// `payment_method_groups`). Groups are already filtered + ordered by the +/// backend — render verbatim. +class PaymentMethodGroup { + final String id; + final String name; + final List methods; + + const PaymentMethodGroup({ + required this.id, + required this.name, + required this.methods, + }); +} + +class PaymentCatalog { + final List groups; + const PaymentCatalog(this.groups); + + /// Whether this came from the hardcoded fallback (catalog endpoint failed). + /// UI uses this to optionally surface a "couldn't load all methods" hint. + final bool isFallback = false; +} + +class _FallbackCatalog extends PaymentCatalog { + const _FallbackCatalog() : super(const [_kFallbackGroup]); + @override + bool get isFallback => true; +} + +const _kFallbackGroup = PaymentMethodGroup( + id: 'fallback-paling-cepat', + name: 'Paling Cepat', + methods: [ + PaymentMethodEntry( + id: 'fallback-qris', + paymentCode: 'QRIS', + displayName: 'QRIS', + icon: 'qris', + ), + ], +); + +const PaymentCatalog kFallbackPaymentCatalog = _FallbackCatalog(); + +/// App-facing catalog. Calls `GET /api/client/payment-methods`; on 5xx or +/// network error returns [kFallbackPaymentCatalog] so checkout never +/// hard-fails. See `requirement/phase5-payment-catalog-plan.md` §5. +final paymentCatalogProvider = FutureProvider((ref) async { + final api = ref.read(apiClientProvider); + try { + final res = await api.get('/api/client/payment-methods'); + final data = res['data'] as Map?; + final raw = data?['groups'] as List? ?? const []; + final groups = raw.map((g) { + final gm = g as Map; + final methods = (gm['methods'] as List? ?? const []).map((m) { + final mm = m as Map; + return PaymentMethodEntry( + id: mm['id'] as String, + paymentCode: mm['payment_code'] as String, + displayName: mm['display_name'] as String, + icon: mm['icon'] as String?, + ); + }).toList(growable: false); + return PaymentMethodGroup( + id: gm['id'] as String, + name: gm['name'] as String, + methods: methods, + ); + }).toList(growable: false); + // Defensive empty-catalog guard: if every group ended up empty after + // parsing, fall back so the user always sees at least QRIS. + if (groups.isEmpty || groups.every((g) => g.methods.isEmpty)) { + return kFallbackPaymentCatalog; + } + return PaymentCatalog(groups); + } catch (_) { + return kFallbackPaymentCatalog; + } +}); diff --git a/client_app/lib/features/payment/widgets/payment_icon.dart b/client_app/lib/features/payment/widgets/payment_icon.dart new file mode 100644 index 0000000..8d3d1fc --- /dev/null +++ b/client_app/lib/features/payment/widgets/payment_icon.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../../core/theme/halo_tokens.dart'; + +/// Slugs we ship SVGs for. Keep this in sync with `assets/payment_icons/`. +/// When a brand mark is added to the bundle (drop the SVG into the asset +/// dir), add its slug here. Anything not in this set renders the placeholder. +/// +/// Source: idn-finlogos (github.com/hafidznoor/idn-finlogos) — see +/// `assets/payment_icons/NOTICE_IDN_FINLOGOS.txt` for licensing terms. +const Set _kBundledSlugs = { + 'qris', + 'ovo', + 'dana', + 'shopeepay', + 'gopay', + 'bca', + 'mandiri', + 'bni', + 'bri', + 'permata', +}; + +/// Renders a payment-method brand mark by slug (`payment_methods.icon`). +/// Falls back to a generic credit-card placeholder when the slug isn't +/// bundled. Slugs are kept lower-case by convention. +class PaymentIcon extends StatelessWidget { + final String? slug; + final double size; + final Color color; + + const PaymentIcon({ + super.key, + required this.slug, + this.size = 24, + this.color = HaloTokens.brandDark, + }); + + @override + Widget build(BuildContext context) { + final isBundled = slug != null && _kBundledSlugs.contains(slug); + final asset = isBundled + ? 'assets/payment_icons/$slug.svg' + : 'assets/payment_icons/placeholder.svg'; + + // Brand SVGs ship with their canonical colors and must NOT be tinted; + // the placeholder is mono-color and DOES want the brand-dark tint. + return SvgPicture.asset( + asset, + width: size, + height: size, + colorFilter: isBundled ? null : ColorFilter.mode(color, BlendMode.srcIn), + ); + } +} diff --git a/client_app/lib/features/splash/splash_screen.dart b/client_app/lib/features/splash/splash_screen.dart index 33bcc7b..9536141 100644 --- a/client_app/lib/features/splash/splash_screen.dart +++ b/client_app/lib/features/splash/splash_screen.dart @@ -1,16 +1,200 @@ -import 'package:flutter/material.dart'; +import 'dart:async'; +import 'dart:ui'; -class SplashScreen extends StatelessWidget { +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/auth/auth_notifier.dart'; +import '../../core/auth/onboarding_intent_provider.dart'; +import '../../core/theme/halo_tokens.dart'; + +/// S1 · Splash — figma-bestie/project/screens/onboarding.jsx +/// +/// Self-driving: waits for both a 1s minimum hold AND the auth provider to +/// resolve, then navigates to the appropriate destination. The router's +/// redirect leaves /splash alone — see [router.dart]. +class SplashScreen extends ConsumerStatefulWidget { const SplashScreen({super.key}); + @override + ConsumerState createState() => _SplashScreenState(); +} + +class _SplashScreenState extends ConsumerState { + bool _holdElapsed = false; + bool _navigated = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + // 2.5s here because the native Android launch splash covers Flutter for + // ~1-1.5s on cold start. By the time the user actually sees this widget, + // ~1s has already burned — 2.5s leaves a comfortable ~1.2s of visible + // splash before we navigate. + Future.delayed(const Duration(milliseconds: 2500), () { + if (!mounted) return; + setState(() => _holdElapsed = true); + _maybeLeave(); + }); + }); + } + + void _maybeLeave() { + if (_navigated || !mounted || !_holdElapsed) return; + final authState = ref.read(authProvider); + if (authState is AsyncLoading) return; + _navigated = true; + + final data = authState.valueOrNull; + if (data is AuthNeedsDisplayNameData) { + context.go('/auth/set-name'); + } else if (data is AuthForceRegisterData) { + context.go('/auth/force-register'); + } else if (data is AuthAuthenticatedData && + ref.read(onboardingIntentProvider) == OnboardingIntent.onboarding) { + context.go('/payment/entry'); + } else { + context.go('/home'); + } + } + @override Widget build(BuildContext context) { + ref.listen(authProvider, (_, __) => _maybeLeave()); return Scaffold( - backgroundColor: Colors.white, - body: Center( - child: Image.asset( - 'assets/images/splash_chat_hebat.png', - width: 200, + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [0.0, 0.6, 1.0], + colors: [ + HaloTokens.brandSofter, + HaloTokens.bg, + HaloTokens.accentSoft, + ], + ), + ), + child: Stack( + children: [ + const Positioned( + top: 80, + right: -40, + child: _Orb( + size: 180, + color: HaloTokens.brand, + opacity: 0.31, + blurSigma: 20, + ), + ), + const Positioned( + bottom: 120, + left: -40, + child: _Orb( + size: 160, + color: HaloTokens.accent, + opacity: 0.38, + blurSigma: 24, + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(28, 40, 28, 40), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 96, + height: 96, + decoration: BoxDecoration( + color: HaloTokens.brandLogoBg, + borderRadius: BorderRadius.circular(24), + boxShadow: HaloShadows.soft, + ), + clipBehavior: Clip.antiAlias, + // logo.png has ~25% internal whitespace around the + // glyph; scale up so the visible art reaches the edges + // of the tile. The Container clip keeps it inside the + // rounded square. + child: Transform.scale( + scale: 1.4, + child: Image.asset( + 'assets/icons/logo.png', + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(height: 28), + const Text( + 'HaloBestie', + style: TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 44, + fontWeight: FontWeight.w700, + height: 1.05, + letterSpacing: -1.1, + color: HaloTokens.brandDark, + ), + ), + const SizedBox(height: 18), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: const Text( + 'kamu gak harus ngerasain\nini sendirian.', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 15, + height: 1.5, + color: HaloTokens.inkSoft, + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _Orb extends StatelessWidget { + const _Orb({ + required this.size, + required this.color, + required this.opacity, + required this.blurSigma, + }); + + final double size; + final Color color; + final double opacity; + final double blurSigma; + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma), + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + center: const Alignment(-0.4, -0.4), + radius: 0.7, + colors: [color.withValues(alpha: opacity), color.withValues(alpha: 0)], + ), + ), ), ), ); diff --git a/client_app/lib/router.dart b/client_app/lib/router.dart index 6af2b2c..d09d95f 100644 --- a/client_app/lib/router.dart +++ b/client_app/lib/router.dart @@ -8,7 +8,6 @@ import 'features/auth/screens/register_screen.dart'; import 'features/auth/screens/otp_screen.dart'; import 'features/auth/screens/force_register_screen.dart'; import 'features/auth/screens/set_display_name_screen.dart'; -import 'features/onboarding/onboarding_screen.dart'; import 'features/onboarding/screens/notif_gate_screen.dart'; import 'features/onboarding/screens/usp_screen.dart'; import 'features/splash/splash_screen.dart'; @@ -53,9 +52,6 @@ class RouterNotifier extends ChangeNotifier { } } -/// Cached onboarding status — loaded once at startup, invalidated after onboarding completes -final onboardingDoneProvider = FutureProvider((ref) => isOnboardingDone()); - final routerProvider = Provider((ref) => buildRouter(ref)); GoRouter buildRouter(Ref ref) { @@ -70,7 +66,6 @@ GoRouter buildRouter(Ref ref) { if (state.matchedLocation == '/_theme_preview') return null; final authState = ref.read(authProvider); final isSplash = state.matchedLocation == '/splash'; - final isOnboarding = state.matchedLocation == '/onboarding'; final isAuthRoute = state.matchedLocation.startsWith('/auth'); // Phase 4 onboarding flow (Verif Choice → ESP → USP) — must transit // freely while authState is AuthAnonymousData so the router doesn't @@ -78,19 +73,14 @@ GoRouter buildRouter(Ref ref) { final isOnboardingFlow = state.matchedLocation.startsWith('/onboarding/'); - // Show splash only during initial load - if (authState is AsyncLoading) { - if (isSplash || isAuthRoute || isOnboarding) return null; - return '/splash'; - } + // SplashScreen is self-driving — it waits for both a 1s minimum hold and + // for `authProvider` to resolve, then navigates explicitly. The router + // must never redirect away from /splash on its own. + if (isSplash) return null; - // Check onboarding status — must complete before anything else - final onboardingDone = ref.read(onboardingDoneProvider).valueOrNull ?? false; - if (!onboardingDone) { - return isOnboarding ? null : '/onboarding'; - } - if (isOnboarding) { - return '/home'; + if (authState is AsyncLoading) { + if (isAuthRoute) return null; + return '/splash'; } final data = authState.valueOrNull; @@ -154,7 +144,6 @@ GoRouter buildRouter(Ref ref) { if (kThemePreviewEnabled) GoRoute(path: '/_theme_preview', builder: (_, __) => const ThemePreviewScreen()), GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()), - GoRoute(path: '/onboarding', builder: (_, __) => const OnboardingScreen()), GoRoute(path: '/auth/display-name', builder: (_, __) => const DisplayNameScreen()), GoRoute(path: '/auth/register', builder: (_, __) => const RegisterScreen()), GoRoute(path: '/auth/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)), diff --git a/client_app/pubspec.lock b/client_app/pubspec.lock index e9f78fd..f455d23 100644 --- a/client_app/pubspec.lock +++ b/client_app/pubspec.lock @@ -470,6 +470,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -775,6 +783,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: transitive description: @@ -1268,6 +1284,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.3" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: b9b3f391857781aa96acacef96066f2f49b4cd03cf9fce3ca4d8da2ef5ea129e + url: "https://pub.dev" + source: hosted + version: "1.2.3" vector_math: dependency: transitive description: diff --git a/client_app/pubspec.yaml b/client_app/pubspec.yaml index d9af16c..a511c57 100644 --- a/client_app/pubspec.yaml +++ b/client_app/pubspec.yaml @@ -42,6 +42,13 @@ dependencies: # (mock mode encodes payment_session_id; real QR will come from Xendit later). qr_flutter: ^4.1.0 + # Payment method icons (Phase 5.x catalog) — bundled SVGs under + # assets/payment_icons/, copied from github.com/hafidznoor/idn-finlogos. + # Xendit's per-channel media-asset pages were planned but found + # decommissioned during implementation. See + # `requirement/phase5-payment-catalog-plan.md` §7 for the sourcing decision. + flutter_svg: ^2.0.10+1 + # OS notification permission — used by the post-payment notif gate # (Phase 4 Stage 4) and the home banner. permission_handler: ^11.3.1 @@ -85,7 +92,8 @@ flutter: uses-material-design: true assets: - assets/images/ - - assets/images/splash/ + - assets/icons/ + - assets/payment_icons/ - assets/fonts/ fonts: diff --git a/control_center/src/App.jsx b/control_center/src/App.jsx index 5ffd587..fc4280e 100644 --- a/control_center/src/App.jsx +++ b/control_center/src/App.jsx @@ -8,6 +8,7 @@ import UsersPage from './pages/users/UsersPage' import SettingsPage from './pages/settings/SettingsPage' import MitraActivityPage from './pages/mitra-activity/MitraActivityPage' import FailedPairingsPage from './pages/failed-pairings/FailedPairingsPage' +import PaymentCatalogPage from './pages/payment-catalog/PaymentCatalogPage' import Layout from './components/Layout' const ProtectedRoute = ({ children }) => { @@ -28,6 +29,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/control_center/src/components/Layout.jsx b/control_center/src/components/Layout.jsx index 6f3c87b..d176ca6 100644 --- a/control_center/src/components/Layout.jsx +++ b/control_center/src/components/Layout.jsx @@ -66,6 +66,7 @@ export default function Layout() {
  • Failed Pairings
  • Users
  • Aktivitas Mitra
  • +
  • Payment Catalog
  • Settings
  • diff --git a/control_center/src/pages/payment-catalog/PaymentCatalogPage.jsx b/control_center/src/pages/payment-catalog/PaymentCatalogPage.jsx new file mode 100644 index 0000000..2e9bc44 --- /dev/null +++ b/control_center/src/pages/payment-catalog/PaymentCatalogPage.jsx @@ -0,0 +1,449 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { apiClient } from '../../core/api/api-client' + +// ─────────────────────────── data fetching ─────────────────────────── + +const fetchGroups = async () => { + const res = await apiClient.get('/internal/payment-groups') + return res.data.data.groups +} + +const fetchMethods = async () => { + const res = await apiClient.get('/internal/payment-methods') + return res.data.data.methods +} + +const errorMessage = (err) => { + const code = err?.response?.data?.error?.code + const msg = err?.response?.data?.error?.message + if (code === 'CONFLICT') return msg || 'Konflik data.' + if (code === 'VALIDATION') return msg || 'Input tidak valid.' + if (code === 'NOT_FOUND') return msg || 'Data tidak ditemukan.' + return msg || 'Gagal menyimpan.' +} + +// ─────────────────────────── page ─────────────────────────── + +export default function PaymentCatalogPage() { + const qc = useQueryClient() + const groupsQ = useQuery({ queryKey: ['payment-groups'], queryFn: fetchGroups }) + const methodsQ = useQuery({ queryKey: ['payment-methods'], queryFn: fetchMethods }) + + const [selectedGroupId, setSelectedGroupId] = useState(null) + const [groupForm, setGroupForm] = useState(null) // null | {id?, name, display_order, is_active} + const [methodForm, setMethodForm] = useState(null) + const [error, setError] = useState(null) + + const invalidate = () => { + qc.invalidateQueries({ queryKey: ['payment-groups'] }) + qc.invalidateQueries({ queryKey: ['payment-methods'] }) + } + + // ─────────────── mutations ─────────────── + + const groupCreate = useMutation({ + mutationFn: (body) => apiClient.post('/internal/payment-groups', body), + onSuccess: () => { invalidate(); setGroupForm(null); setError(null) }, + onError: (e) => setError(errorMessage(e)), + }) + const groupUpdate = useMutation({ + mutationFn: ({ id, body }) => apiClient.patch(`/internal/payment-groups/${id}`, body), + onSuccess: () => { invalidate(); setGroupForm(null); setError(null) }, + onError: (e) => setError(errorMessage(e)), + }) + const groupDelete = useMutation({ + mutationFn: (id) => apiClient.delete(`/internal/payment-groups/${id}`), + onSuccess: () => { invalidate(); setError(null) }, + onError: (e) => setError(errorMessage(e)), + }) + const groupsReorder = useMutation({ + mutationFn: (orderedIds) => apiClient.post('/internal/payment-groups/reorder', { ordered_ids: orderedIds }), + onSuccess: invalidate, + onError: (e) => setError(errorMessage(e)), + }) + + const methodCreate = useMutation({ + mutationFn: (body) => apiClient.post('/internal/payment-methods', body), + onSuccess: () => { invalidate(); setMethodForm(null); setError(null) }, + onError: (e) => setError(errorMessage(e)), + }) + const methodUpdate = useMutation({ + mutationFn: ({ id, body }) => apiClient.patch(`/internal/payment-methods/${id}`, body), + onSuccess: () => { invalidate(); setMethodForm(null); setError(null) }, + onError: (e) => setError(errorMessage(e)), + }) + const methodDelete = useMutation({ + mutationFn: (id) => apiClient.delete(`/internal/payment-methods/${id}`), + onSuccess: () => { invalidate(); setError(null) }, + onError: (e) => setError(errorMessage(e)), + }) + const methodsReorder = useMutation({ + mutationFn: (orderedIds) => apiClient.post('/internal/payment-methods/reorder', { ordered_ids: orderedIds }), + onSuccess: invalidate, + onError: (e) => setError(errorMessage(e)), + }) + + // ─────────────── helpers ─────────────── + + const moveGroup = (id, delta) => { + const ordered = (groupsQ.data ?? []).map((g) => g.id) + const idx = ordered.indexOf(id) + const newIdx = idx + delta + if (idx < 0 || newIdx < 0 || newIdx >= ordered.length) return + ;[ordered[idx], ordered[newIdx]] = [ordered[newIdx], ordered[idx]] + groupsReorder.mutate(ordered) + } + + const moveMethod = (id, delta, groupId) => { + const inGroup = (methodsQ.data ?? []) + .filter((m) => m.group_id === groupId) + .map((m) => m.id) + const idx = inGroup.indexOf(id) + const newIdx = idx + delta + if (idx < 0 || newIdx < 0 || newIdx >= inGroup.length) return + ;[inGroup[idx], inGroup[newIdx]] = [inGroup[newIdx], inGroup[idx]] + methodsReorder.mutate(inGroup) + } + + if (groupsQ.isLoading || methodsQ.isLoading) return
    Loading…
    + if (groupsQ.error || methodsQ.error) return
    Failed to load payment catalog.
    + + const groups = groupsQ.data ?? [] + const methods = methodsQ.data ?? [] + const filteredMethods = selectedGroupId + ? methods.filter((m) => m.group_id === selectedGroupId) + : methods + + return ( +
    +

    Payment Catalog

    +

    + Groups and methods rendered by the customer app on the "cara bayar" + screen. Reorder controls the visible order. payment_code must + match the Xendit channel code exactly (uppercase, e.g. OVO, + BCA_VA). +

    + + {error && ( +
    + {error} +
    + )} + + {/* ───────────── groups ───────────── */} +
    +
    +

    Groups

    + +
    + + + + + + + + + + + + {groups.map((g, i) => { + const methodsInGroup = methods.filter((m) => m.group_id === g.id).length + return ( + + + + + + + + ) + })} + {groups.length === 0 && ( + + )} + +
    OrderNameActiveMethodsActions
    +
    + + + {g.display_order} +
    +
    {g.name}{g.is_active ? '✓' : '✗'}{methodsInGroup} + + + +
    No groups yet.
    +
    + + {/* ───────────── methods ───────────── */} +
    +
    +

    + Methods{selectedGroupId && groups.find(g => g.id === selectedGroupId) + ? ` — ${groups.find(g => g.id === selectedGroupId).name}` + : ''} +

    + +
    + + + + + + + + + + + + + + {filteredMethods.map((m, i, arr) => ( + + + + + + + + + + ))} + {filteredMethods.length === 0 && ( + + )} + +
    OrderGroupDisplay nameCodeIcon slugActiveActions
    +
    + + + {m.display_order} +
    +
    {groups.find((g) => g.id === m.group_id)?.name ?? '—'}{m.display_name}{m.payment_code}{m.icon || }{m.is_active ? '✓' : '✗'} + + +
    + {selectedGroupId ? 'No methods in this group yet.' : 'No methods yet.'} +
    +
    + + {/* ───────────── modals ───────────── */} + {groupForm && ( + { setGroupForm(null); setError(null) }} + onSubmit={() => { + const body = { + name: groupForm.name?.trim(), + display_order: Number(groupForm.display_order) || 0, + is_active: !!groupForm.is_active, + } + if (groupForm.id) { + groupUpdate.mutate({ id: groupForm.id, body }) + } else { + groupCreate.mutate(body) + } + }} + /> + )} + + {methodForm && ( + { setMethodForm(null); setError(null) }} + onSubmit={() => { + const body = { + group_id: methodForm.group_id, + display_name: methodForm.display_name?.trim(), + payment_code: methodForm.payment_code?.trim()?.toUpperCase(), + display_order: Number(methodForm.display_order) || 0, + icon: methodForm.icon?.trim() || null, + is_active: !!methodForm.is_active, + } + if (methodForm.id) { + methodUpdate.mutate({ id: methodForm.id, body }) + } else { + methodCreate.mutate(body) + } + }} + /> + )} +
    + ) +} + +// ─────────────────────────── modals ─────────────────────────── + +const GroupModal = ({ form, onChange, onSubmit, onClose }) => ( + + + onChange({ ...form, name: e.target.value })} + style={inputStyle} + /> + + + onChange({ ...form, display_order: e.target.value })} + style={inputStyle} + /> + + + onChange({ ...form, is_active: e.target.checked })} + /> + +