diff --git a/backend/.dev/inspect-payment-catalog.js b/backend/.dev/inspect-payment-catalog.js new file mode 100644 index 0000000..4ec3e7b --- /dev/null +++ b/backend/.dev/inspect-payment-catalog.js @@ -0,0 +1,31 @@ +// Dump the current payment catalog for a quick visual sanity check after a +// reset. Read-only. + +import 'dotenv/config' +import { getDb } from '../src/db/client.js' + +const sql = getDb() +const rows = await sql` + SELECT g.name AS grp, m.display_name, m.payment_code, m.icon, + m.min_amount, m.max_amount, m.is_active + FROM payment_methods m + JOIN payment_method_groups g ON m.group_id = g.id + ORDER BY g.display_order, m.display_order +` +console.log(`${rows.length} methods across ${new Set(rows.map(r => r.grp)).size} groups\n`) +const w = (s, n) => String(s).padEnd(n) +const r = (s, n) => String(s).padStart(n) +console.log(w('Group', 18), w('Display', 26), w('Code', 26), w('Icon', 26), r('Min', 10), r('Max', 16)) +console.log('-'.repeat(124)) +for (const row of rows) { + console.log( + w(row.grp, 18), + w(row.display_name, 26), + w(row.payment_code, 26), + w(row.icon ?? '—', 26), + r(row.min_amount ?? '—', 10), + r(row.max_amount ?? '—', 16), + row.is_active ? '' : '(inactive)', + ) +} +await sql.end() diff --git a/backend/.dev/reset-payment-catalog.js b/backend/.dev/reset-payment-catalog.js new file mode 100644 index 0000000..75d3933 --- /dev/null +++ b/backend/.dev/reset-payment-catalog.js @@ -0,0 +1,28 @@ +// One-shot wipe of the payment_methods + payment_method_groups tables in the +// current DATABASE_URL. Use when you want the seed in migrate.js to repopulate +// from scratch on the next migration run. +// +// Safe against payment_requests because that table does NOT FK into +// payment_methods — `xendit_payment_method` and `product_metadata.preferred_payment_code` +// are free-text columns. +// +// Usage: +// cd backend && node .dev/reset-payment-catalog.js +// cd backend && node src/db/migrate.js + +import 'dotenv/config' +import { getDb } from '../src/db/client.js' + +const sql = getDb() + +const [{ count: methodCount }] = await sql`SELECT COUNT(*)::int AS count FROM payment_methods` +const [{ count: groupCount }] = await sql`SELECT COUNT(*)::int AS count FROM payment_method_groups` +console.log(`Before: ${methodCount} methods, ${groupCount} groups`) + +// FK is payment_methods.group_id → payment_method_groups (ON DELETE RESTRICT), +// so methods must go first. +await sql`DELETE FROM payment_methods` +await sql`DELETE FROM payment_method_groups` + +console.log('Wiped. Now run: node src/db/migrate.js') +await sql.end() diff --git a/backend/package-lock.json b/backend/package-lock.json index aeca6c3..b95f4f9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -17,6 +17,7 @@ "fastify": "^5.0.0", "firebase-admin": "^12.2.0", "google-auth-library": "^9.15.1", + "idn-finlogos": "^2.3.0", "ioredis": "^5.4.1", "jsonwebtoken": "^9.0.3", "jwks-rsa": "^3.2.2", @@ -2642,6 +2643,39 @@ "node": ">= 14" } }, + "node_modules/idn-finlogos": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/idn-finlogos/-/idn-finlogos-2.3.0.tgz", + "integrity": "sha512-s6kF3gPvcW+hdRJdMomKiH7m05X77VAltj2Z1FBQuP00pJxBMDULTOjK4bCsrq6LQ8xVATMGR130fSzdidwKUA==", + "license": "SEE LICENSE IN LICENSE", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": ">=16.8", + "react-native": ">=0.60", + "react-native-svg": ">=12.0", + "svelte": ">=4.0", + "vue": ">=3.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-native": { + "optional": true + }, + "react-native-svg": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", diff --git a/backend/package.json b/backend/package.json index d012770..a2a61bc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,6 +23,7 @@ "fastify": "^5.0.0", "firebase-admin": "^12.2.0", "google-auth-library": "^9.15.1", + "idn-finlogos": "^2.3.0", "ioredis": "^5.4.1", "jsonwebtoken": "^9.0.3", "jwks-rsa": "^3.2.2", diff --git a/backend/src/app.internal.js b/backend/src/app.internal.js index 117bdbe..7d15d08 100644 --- a/backend/src/app.internal.js +++ b/backend/src/app.internal.js @@ -11,6 +11,7 @@ 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 { internalPaymentIconsRoutes } from './routes/internal/payment-icons.routes.js' import { internalTestRoutes } from './routes/internal/_test.routes.js' import { errorHandler } from './plugins/error-handler.js' @@ -40,6 +41,7 @@ export const buildInternalApp = async () => { app.register(mitraActivityRoutes, { prefix: '/internal/mitra-activity' }) app.register(failedPairingsRoutes, { prefix: '/internal/failed-pairings' }) app.register(internalPaymentCatalogRoutes, { prefix: '/internal' }) + app.register(internalPaymentIconsRoutes, { 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 d924e20..4195790 100644 --- a/backend/src/app.public.js +++ b/backend/src/app.public.js @@ -17,6 +17,8 @@ import { clientOnboardingRoutes } from './routes/public/client.onboarding.routes import { sharedSupportRoutes } from './routes/public/shared.support.routes.js' import { sharedChatRoutes } from './routes/public/shared.chat.routes.js' import { paymentWebhookRoutes } from './routes/public/shared.payment-webhooks.routes.js' +import { paymentIconRoutes } from './routes/public/shared.payment-icons.routes.js' +import { paymentReturnRoutes } from './routes/public/payment-return.routes.js' import { errorHandler } from './plugins/error-handler.js' import { registerWebSocketPlugin, registerWebSocketRoute } from './plugins/websocket.js' @@ -47,6 +49,10 @@ export const buildPublicApp = async () => { app.register(sharedSupportRoutes, { prefix: '/api/shared' }) // Payment provider webhooks. Public + token-authed via x-callback-token. app.register(paymentWebhookRoutes, { prefix: '/api/shared/payment' }) + // Brand-mark SVGs from idn-finlogos. Public, 1-year immutable cache. + app.register(paymentIconRoutes, { prefix: '/assets' }) + // Xendit customer-redirect HTML pages. Public, no auth. + app.register(paymentReturnRoutes, { prefix: '/payment' }) // WebSocket route (registered at app level, not prefixed) registerWebSocketRoute(app) diff --git a/backend/src/db/migrate.js b/backend/src/db/migrate.js index dd59ae4..05c9dab 100644 --- a/backend/src/db/migrate.js +++ b/backend/src/db/migrate.js @@ -1191,69 +1191,125 @@ const migrate = async () => { 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 + // Per-method amount bounds (Phase 5.x). Both inclusive, both nullable + // (null = no bound). The customer app greys out methods whose bounds the + // current bill misses; the backend rejects with INVALID_PAYMENT_AMOUNT. + // BIGINT so we can store Xendit's published per-channel ceilings verbatim + // (some banks document up to Rp 50 billion, well past INT range). + await sql` + ALTER TABLE payment_methods + ADD COLUMN IF NOT EXISTS min_amount BIGINT, + ADD COLUMN IF NOT EXISTS max_amount BIGINT ` - 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 + // Unique on group name so we can re-run the seed below with ON CONFLICT. + await sql` + CREATE UNIQUE INDEX IF NOT EXISTS payment_method_groups_name_uq + ON payment_method_groups (name) + ` + + // --- Catalog seed ----------------------------------------------------------- + // + // Re-runnable: groups upsert via ON CONFLICT on name; methods via ON CONFLICT + // on payment_code (already UNIQUE). Operator edits in CC are NOT clobbered + // because DO NOTHING leaves existing rows alone. New methods you add to this + // list later land on the next migration; existing methods don't get bumped. + // + // Limits are pulled from https://docs.xendit.co/docs/available-payment-channels + // (verified 2026-05-27). Amounts are inclusive Rp. Where Xendit's documented + // max exceeds 50B, we keep the literal number — `payment_requests.amount` is + // INTEGER-capped at 2.1B so we never get that high in practice; the bound is + // recorded faithfully for documentation / future raises. + const PAYMENT_CATALOG_SEED = [ + { + name: 'Paling Cepat', + order: 0, + methods: [ + { code: 'QRIS', display: 'QRIS', icon: 'qris', + min: 1500, max: 20000000, active: true }, + ], + }, + { + name: 'E-Wallet', + order: 1, + methods: [ + { code: 'OVO', display: 'OVO', icon: 'ovo-new', min: 100, max: 20000000, active: true }, + { code: 'DANA', display: 'DANA', icon: 'dana', min: 100, max: 20000000, active: true }, + { code: 'SHOPEEPAY', display: 'ShopeePay', icon: 'shopee-pay', min: 1, max: 20000000, active: true }, + { code: 'LINKAJA', display: 'LinkAja', icon: 'linkaja', min: 1, max: 20000000, active: true }, + { code: 'ASTRAPAY', display: 'AstraPay', icon: 'astra-pay', min: 100, max: 20000000, active: true }, + ], + }, + { + name: 'Virtual Account', + order: 2, + methods: [ + { code: 'BCA_VIRTUAL_ACCOUNT', display: 'BCA Virtual Account', icon: 'bca', min: 10000, max: 50000000, active: true }, + { code: 'MANDIRI_VIRTUAL_ACCOUNT', display: 'Mandiri Virtual Account', icon: 'mandiri', min: 1, max: 50000000000n, active: true }, + { code: 'BNI_VIRTUAL_ACCOUNT', display: 'BNI Virtual Account', icon: 'bni', min: 1, max: 50000000, active: true }, + { code: 'BRI_VIRTUAL_ACCOUNT', display: 'BRI Virtual Account', icon: 'bri', min: 1, max: 50000000000n, active: true }, + { code: 'BSI_VIRTUAL_ACCOUNT', display: 'BSI Virtual Account', icon: 'bsi', min: 1, max: 50000000000n, active: true }, + { code: 'PERMATA_VIRTUAL_ACCOUNT', display: 'Permata Virtual Account', icon: 'permata', min: 1, max: 9999999999n, active: true }, + { code: 'CIMB_VIRTUAL_ACCOUNT', display: 'CIMB Virtual Account', icon: 'cimb-niaga', min: 1, max: 50000000, active: true }, + { code: 'BJB_VIRTUAL_ACCOUNT', display: 'BJB Virtual Account', icon: 'bank-bjb', min: 1, max: 2000000000, active: true }, + { code: 'BSS_VIRTUAL_ACCOUNT', display: 'BSS Virtual Account', icon: 'bank-sahabat-sampoerna', min: 1, max: 50000000000n, active: true }, + ], + }, + { + name: 'Outlet', + order: 3, + methods: [ + { code: 'ALFAMART', display: 'Alfamart', icon: 'alfamart', min: 10000, max: 5000000, active: true }, + { code: 'INDOMARET', display: 'Indomaret', icon: 'indomaret', min: 10000, max: 2500000, active: true }, + ], + }, + { + name: 'Kartu Kredit', + order: 4, + methods: [ + // `icon` is comma-separated → backend emits multiple icon_urls; client + // renders them side-by-side on the same tile. + { code: 'CARDS', display: 'Kartu Kredit', icon: 'visa,mastercard,jcb', + min: 5000, max: 200000000, active: true }, + ], + }, + ] + + for (const group of PAYMENT_CATALOG_SEED) { + // INSERT new group OR fetch existing's id. ON CONFLICT (name) DO NOTHING + // returns no row, so we RETURNING + fallback SELECT. + const ins = await sql` + INSERT INTO payment_method_groups (name, display_order, is_active) + VALUES (${group.name}, ${group.order}, true) + ON CONFLICT (name) DO NOTHING + RETURNING id + ` + let groupId + if (ins.length > 0) { + groupId = ins[0].id + } else { + const [row] = await sql`SELECT id FROM payment_method_groups WHERE name = ${group.name}` + groupId = row.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, + min_amount, max_amount, is_active + ) + VALUES ( + ${groupId}, + ${m.display}, + ${m.code}, + ${methodOrder++}, + ${m.icon}, + ${m.min}, + ${m.max}, + ${m.active} + ) + ON CONFLICT (payment_code) DO NOTHING ` - 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 - ` - } } } diff --git a/backend/src/routes/internal/payment-catalog.routes.js b/backend/src/routes/internal/payment-catalog.routes.js index 1ff790e..3467c6d 100644 --- a/backend/src/routes/internal/payment-catalog.routes.js +++ b/backend/src/routes/internal/payment-catalog.routes.js @@ -53,6 +53,22 @@ const conflict = (message, extra = {}) => ({ error: { code: 'CONFLICT', message, ...extra }, }) +// Amount bounds are inclusive Rupiah, null = no bound. Accept either null or +// a non-negative finite integer. We coerce empty string / "" to null on input +// so the CC form can clear a bound by submitting an empty field. +const normalizeAmountBound = (raw) => { + if (raw === null || raw === undefined || raw === '') return null + return raw +} + +const validateAmountBound = (fieldName, raw) => { + if (raw === null || raw === undefined || raw === '') return null + if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < 0) { + return validation(`${fieldName} must be a non-negative integer or null`, fieldName) + } + return null +} + const attachCcUser = async (request, reply) => { if (request.auth?.userType !== UserType.CC_USER) { return reply.code(403).send({ @@ -157,7 +173,8 @@ export const internalPaymentCatalogRoutes = async (app) => { }) app.post('/payment-methods', { preHandler: WRITE_GUARD }, async (request, reply) => { - const { group_id, display_name, payment_code, display_order, icon, is_active } = request.body ?? {} + const { group_id, display_name, payment_code, display_order, icon, + min_amount, max_amount, 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')) } @@ -167,6 +184,13 @@ export const internalPaymentCatalogRoutes = async (app) => { if (typeof payment_code !== 'string' || payment_code.trim().length === 0) { return reply.code(422).send(validation('payment_code is required', 'payment_code')) } + const minErr = validateAmountBound('min_amount', min_amount) + if (minErr) return reply.code(422).send(minErr) + const maxErr = validateAmountBound('max_amount', max_amount) + if (maxErr) return reply.code(422).send(maxErr) + if (min_amount != null && max_amount != null && min_amount > max_amount) { + return reply.code(422).send(validation('min_amount must be <= max_amount', 'min_amount')) + } try { const row = await createMethod({ groupId: group_id, @@ -174,6 +198,8 @@ export const internalPaymentCatalogRoutes = async (app) => { paymentCode: payment_code.trim(), displayOrder: Number.isFinite(display_order) ? display_order : 0, icon: typeof icon === 'string' && icon.trim().length > 0 ? icon.trim() : null, + minAmount: normalizeAmountBound(min_amount), + maxAmount: normalizeAmountBound(max_amount), isActive: typeof is_active === 'boolean' ? is_active : true, }) return reply.code(201).send({ success: true, data: row }) @@ -198,7 +224,8 @@ export const internalPaymentCatalogRoutes = async (app) => { 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 ?? {} + const body = request.body ?? {} + const { group_id, display_name, payment_code, display_order, icon, is_active } = 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')) } @@ -208,6 +235,24 @@ export const internalPaymentCatalogRoutes = async (app) => { 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')) } + const hasMin = Object.prototype.hasOwnProperty.call(body, 'min_amount') + const hasMax = Object.prototype.hasOwnProperty.call(body, 'max_amount') + if (hasMin) { + const err = validateAmountBound('min_amount', body.min_amount) + if (err) return reply.code(422).send(err) + } + if (hasMax) { + const err = validateAmountBound('max_amount', body.max_amount) + if (err) return reply.code(422).send(err) + } + // Cross-field check uses post-patch effective values. If only one side is + // patched, the other comes from the existing row — fetched in the service + // would be cleaner, but we'd need a SELECT round-trip; instead, require + // the operator to send both when narrowing the range. + if (hasMin && hasMax && body.min_amount != null && body.max_amount != null + && body.min_amount > body.max_amount) { + return reply.code(422).send(validation('min_amount must be <= max_amount', 'min_amount')) + } try { const row = await updateMethod(id, { groupId: group_id, @@ -215,6 +260,8 @@ export const internalPaymentCatalogRoutes = async (app) => { 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), + ...(hasMin ? { minAmount: normalizeAmountBound(body.min_amount) } : {}), + ...(hasMax ? { maxAmount: normalizeAmountBound(body.max_amount) } : {}), isActive: typeof is_active === 'boolean' ? is_active : undefined, }) if (!row) return reply.code(404).send(notFound('payment_method not found')) diff --git a/backend/src/routes/internal/payment-icons.routes.js b/backend/src/routes/internal/payment-icons.routes.js new file mode 100644 index 0000000..efb0e54 --- /dev/null +++ b/backend/src/routes/internal/payment-icons.routes.js @@ -0,0 +1,40 @@ +/** + * Control-center read-only manifest for the payment-icon picker. + * + * GET /internal/payment-icons → { slugs: [...] } + * + * The CC payment-method form uses this to populate a dropdown of valid + * `icon` values, so operators pick from a known list instead of typing free + * text and risking a 404 on the asset endpoint. Reuses the `config` `read` + * permission (same scope used by the catalog editor). + */ + +import { authenticate, requirePermission } from '../../plugins/auth.js' +import { getCcUserById } from '../../services/cc-user.service.js' +import { UserType } from '../../constants.js' +import { listIconSlugs } from '../../services/payment-icon.service.js' + +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')] + +export const internalPaymentIconsRoutes = async (app) => { + app.get('/payment-icons', { preHandler: READ_GUARD }, async (_request, reply) => { + return reply.send({ success: true, data: { slugs: listIconSlugs() } }) + }) +} diff --git a/backend/src/routes/public/client.payment.routes.js b/backend/src/routes/public/client.payment.routes.js index 303e270..6f04c6c 100644 --- a/backend/src/routes/public/client.payment.routes.js +++ b/backend/src/routes/public/client.payment.routes.js @@ -59,6 +59,9 @@ export const clientPaymentRoutes = async (app) => { // 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. + // Amount-bound enforcement happens AFTER amount is computed below; we + // capture `methodEntry` here to avoid a second lookup. + let methodEntry = null if (method !== null && method !== undefined) { if (typeof method !== 'string' || method.trim().length === 0) { return reply.code(422).send({ @@ -66,8 +69,8 @@ export const clientPaymentRoutes = async (app) => { error: { code: 'VALIDATION_ERROR', message: 'method must be a non-empty string when provided' }, }) } - const entry = await findActiveMethodByCode(method) - if (!entry) { + methodEntry = await findActiveMethodByCode(method) + if (!methodEntry) { return reply.code(422).send({ success: false, error: { @@ -129,6 +132,32 @@ export const clientPaymentRoutes = async (app) => { } } + // Per-method amount bounds — defense in depth alongside the client's own + // disabled-tile UX. A stale catalog cache on the client could let a request + // through that the picker should have blocked. Bounds are inclusive. + if (methodEntry) { + if (methodEntry.min_amount != null && amount < methodEntry.min_amount) { + return reply.code(422).send({ + success: false, + error: { + code: 'INVALID_PAYMENT_AMOUNT', + message: 'Amount below the minimum for the selected payment method', + details: { amount, min_amount: methodEntry.min_amount, max_amount: methodEntry.max_amount }, + }, + }) + } + if (methodEntry.max_amount != null && amount > methodEntry.max_amount) { + return reply.code(422).send({ + success: false, + error: { + code: 'INVALID_PAYMENT_AMOUNT', + message: 'Amount above the maximum for the selected payment method', + details: { amount, min_amount: methodEntry.min_amount, max_amount: methodEntry.max_amount }, + }, + }) + } + } + // Phase 5: payment.service.js handles the Xendit invoice creation internally // when XENDIT_ENABLED=true. The row comes back with xendit_invoice_url populated; // when off, invoice_url is null and the dev/Maestro stub plays the webhook role. diff --git a/backend/src/routes/public/payment-return.routes.js b/backend/src/routes/public/payment-return.routes.js new file mode 100644 index 0000000..3f17a51 --- /dev/null +++ b/backend/src/routes/public/payment-return.routes.js @@ -0,0 +1,154 @@ +/** + * Customer-facing return pages for Xendit Invoice checkout — Phase 5.x. + * + * Routes (public, no auth — Xendit's hosted checkout 302s the customer's + * browser here after payment): + * GET /payment/return/success + * GET /payment/return/failure + * + * Xendit appends query params like `external_id` and `payment_id`; we don't + * need them — the customer's app polls `GET /payment-requests/:id` + * independently and learns the outcome from there. These pages are PURE UX: + * they confirm the outcome and offer a `halobestie://` deeplink button so + * the customer can flip back to the app with one tap. + * + * The deeplink scheme must be registered in + * `client_app/android/app/src/main/AndroidManifest.xml` and + * `client_app/ios/Runner/Info.plist`. If the scheme isn't registered the + * button is a no-op; the customer can still tap the Custom Tab back arrow. + * + * Brand colors mirror `client_app/lib/core/theme/halo_tokens.dart`: + * brand #E17A9D + * brandDark #8C3255 + * brandSofter #FBEFF3 + * danger (red shades — failure variant) + */ + +const renderPage = ({ variant, title, headline, body, deeplink }) => { + const accent = variant === 'success' ? '#E17A9D' : '#D86B6B' + const accentDark = variant === 'success' ? '#8C3255' : '#7A1E1E' + const accentSoft = variant === 'success' ? '#FBEFF3' : '#FCEDED' + const glyph = variant === 'success' ? '✓' : '!' + return ` + + + + + + ${title} + + + + + + +
+
+ +

${headline}

+

${body}

+ Buka HaloBestie +
Atau tutup tab ini untuk kembali ke aplikasi.
+
+
+ +` +} + +export const paymentReturnRoutes = async (app) => { + app.get('/return/success', async (_request, reply) => { + return reply.type('text/html; charset=utf-8').send(renderPage({ + variant: 'success', + title: 'Pembayaran berhasil — HaloBestie', + headline: 'Pembayaran berhasil!', + body: 'Sesi kamu sedang disiapkan. Buka aplikasi HaloBestie untuk mulai curhat.', + deeplink: 'halobestie://payment/return?status=success', + })) + }) + + app.get('/return/failure', async (_request, reply) => { + return reply.type('text/html; charset=utf-8').send(renderPage({ + variant: 'failure', + title: 'Pembayaran tidak berhasil — HaloBestie', + headline: 'Pembayaran tidak berhasil', + body: 'Tenang, belum ada yang ditarik. Buka aplikasi HaloBestie dan coba metode lain.', + deeplink: 'halobestie://payment/return?status=failure', + })) + }) +} diff --git a/backend/src/routes/public/shared.payment-icons.routes.js b/backend/src/routes/public/shared.payment-icons.routes.js new file mode 100644 index 0000000..de88757 --- /dev/null +++ b/backend/src/routes/public/shared.payment-icons.routes.js @@ -0,0 +1,40 @@ +/** + * Public payment-icon serving — Phase 5.x. + * + * GET /assets/payment-icons/:slug.svg + * Returns the idn-finlogos SVG for `slug` with a 1-year immutable cache + * header. Content is stable per backend deploy (icons change only when the + * `idn-finlogos` npm dep is bumped); clients can cache aggressively. + * + * Public on purpose — these are brand-mark icons, not sensitive data. The + * catalog endpoint (`GET /api/client/payment-methods`) is still authenticated; + * leaking the icon URL by itself reveals nothing useful. + * + * 404 on unknown slug. We deliberately do NOT 200-with-placeholder here — + * upstream owns the "show placeholder" fallback, and 404ing tells operators + * about typo'd slugs in the CC payment-method form. + */ + +import { createReadStream } from 'fs' +import { hasIconSlug, resolveIconPath } from '../../services/payment-icon.service.js' + +const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/ + +export const paymentIconRoutes = async (app) => { + app.get('/payment-icons/:slug.svg', async (request, reply) => { + const { slug } = request.params + + // Guard against path-traversal / oversized slug before touching the FS. + if (!SLUG_RE.test(slug) || !hasIconSlug(slug)) { + return reply.code(404).send({ + success: false, + error: { code: 'NOT_FOUND', message: 'Unknown payment icon slug' }, + }) + } + + return reply + .type('image/svg+xml') + .header('Cache-Control', 'public, max-age=31536000, immutable') + .send(createReadStream(resolveIconPath(slug))) + }) +} diff --git a/backend/src/services/payment-catalog.service.js b/backend/src/services/payment-catalog.service.js index 0b4c7b0..a69bdf2 100644 --- a/backend/src/services/payment-catalog.service.js +++ b/backend/src/services/payment-catalog.service.js @@ -27,7 +27,31 @@ import { getValkeyClient, publish, subscribe } from '../plugins/valkey.js' const sql = getDb() -const CACHE_KEY = 'payment-catalog:v1' +// Bump the version suffix whenever the catalog shape changes so a deploy +// doesn't serve stale-shape entries from L2 Valkey for up to VALKEY_TTL_SECONDS. +// v2: added icon_url alongside icon (2026-05-27, Phase 5.x backend icon hosting). +// v3: added min_amount / max_amount per method (2026-05-27, Phase 5.x amount bounds). +// v4: icon -> comma-separated slug list, emit icon_urls[] (2026-05-27, multi-logo for cards). +const CACHE_KEY = 'payment-catalog:v4' + +// Split a comma-separated `icon` string into trimmed, non-empty slugs. +// Tolerates whitespace and stray commas: " visa , mastercard , " → ['visa','mastercard']. +const parseIconSlugs = (raw) => { + if (!raw) return [] + return String(raw) + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0) +} + +// `postgres` returns BIGINT columns as JS strings (BigInt would break JSON.stringify). +// All realistic payment amounts fit comfortably below Number.MAX_SAFE_INTEGER, so we +// coerce to Number for the API response — keeps the wire shape `{min_amount: 10000}` +// rather than `"10000"`, which is what mobile/CC parsers expect. +const coerceAmount = (v) => { + if (v === null || v === undefined) return null + return typeof v === 'number' ? v : Number(v) +} const VALKEY_TTL_SECONDS = 60 * 60 // 1 hour const INPROCESS_TTL_MS = 60 * 1000 // 60 seconds const INVALIDATE_CHANNEL = 'config:invalidate' @@ -93,6 +117,8 @@ const buildCatalogFromDb = async () => { m.payment_code AS payment_code, m.display_name AS display_name, m.icon AS icon, + m.min_amount AS min_amount, + m.max_amount AS max_amount, m.display_order AS method_order FROM payment_method_groups g JOIN payment_methods m ON m.group_id = g.id @@ -111,11 +137,19 @@ const buildCatalogFromDb = async () => { methods: [], }) } + // `icon` is a comma-separated slug list (single entry for most methods; + // multiple for composite tiles like a credit-card row showing Visa + MC + JCB). + // We emit `icon_urls` as the canonical field; clients render them in a row. + const slugs = parseIconSlugs(r.icon) byGroupId.get(r.group_id).methods.push({ id: r.method_id, payment_code: r.payment_code, display_name: r.display_name, icon: r.icon, + icon_urls: slugs.map((s) => `/assets/payment-icons/${s}.svg`), + // Per-method amount bounds (inclusive). Either may be null = no bound. + min_amount: coerceAmount(r.min_amount), + max_amount: coerceAmount(r.max_amount), order: r.method_order, }) } @@ -193,18 +227,23 @@ 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 + icon, min_amount, max_amount, 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 + icon, min_amount, max_amount, is_active, created_at, updated_at FROM payment_methods ORDER BY display_order ASC, display_name ASC ` - return rows + // BIGINT → number for CC consumption (table uses toLocaleString). + return rows.map((r) => ({ + ...r, + min_amount: coerceAmount(r.min_amount), + max_amount: coerceAmount(r.max_amount), + })) } // --- Catalog mutators (used by control-center routes) ------------------------ @@ -270,11 +309,14 @@ export const createMethod = async ({ paymentCode, displayOrder = 0, icon = null, + minAmount = null, + maxAmount = null, isActive = true, }) => { const [row] = await sql` INSERT INTO payment_methods ( - group_id, display_name, payment_code, display_order, icon, is_active + group_id, display_name, payment_code, display_order, icon, + min_amount, max_amount, is_active ) VALUES ( ${groupId}, @@ -282,15 +324,22 @@ export const createMethod = async ({ ${String(paymentCode).toUpperCase()}, ${displayOrder}, ${icon}, + ${minAmount}, + ${maxAmount}, ${isActive} ) - RETURNING id, group_id, display_name, payment_code, display_order, icon, is_active + RETURNING id, group_id, display_name, payment_code, display_order, + icon, min_amount, max_amount, is_active ` await invalidatePaymentCatalog() return row } export const updateMethod = async (id, patch) => { + // null is a meaningful update for min/max (operator clearing the bound), so + // we route those through a sentinel-aware branch instead of COALESCE. + const setMin = Object.prototype.hasOwnProperty.call(patch, 'minAmount') + const setMax = Object.prototype.hasOwnProperty.call(patch, 'maxAmount') const [row] = await sql` UPDATE payment_methods SET @@ -299,10 +348,13 @@ export const updateMethod = async (id, patch) => { 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), + min_amount = ${setMin ? patch.minAmount : sql`min_amount`}, + max_amount = ${setMax ? patch.maxAmount : sql`max_amount`}, 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 + RETURNING id, group_id, display_name, payment_code, display_order, + icon, min_amount, max_amount, is_active ` await invalidatePaymentCatalog() return row diff --git a/backend/src/services/payment-icon.service.js b/backend/src/services/payment-icon.service.js new file mode 100644 index 0000000..a1cb944 --- /dev/null +++ b/backend/src/services/payment-icon.service.js @@ -0,0 +1,41 @@ +/** + * Payment-icon serving — Phase 5.x icon hosting. + * + * Backend wraps the `idn-finlogos` npm package and serves its SVGs at + * `/assets/payment-icons/.svg`. The catalog endpoint returns + * `icon_url: "/assets/payment-icons/.svg"`; the client app fetches + + * caches with `flutter_cache_manager`. No bundled icons in the app anymore. + * + * License: idn-finlogos ships under CC-BY-NC-4.0 for the assets (per-brand + * permission still required for production use). Code under MIT. See + * `backend/node_modules/idn-finlogos/LICENSE-ASSETS` + `NOTICE`. + * + * Slug set is loaded ONCE at module init from `dist/icons/*.svg` filenames so + * the request path stays a single Set.has() lookup. Bump the package version + * to add/replace icons; restart the backend to refresh the slug set. + */ + +import { readdirSync } from 'fs' +import { dirname, join } from 'path' +import { createRequire } from 'module' + +const require = createRequire(import.meta.url) + +const PACKAGE_DIR = dirname(require.resolve('idn-finlogos/package.json')) +const ICONS_DIR = join(PACKAGE_DIR, 'dist', 'icons') + +const SLUGS = new Set( + readdirSync(ICONS_DIR) + .filter((f) => f.endsWith('.svg')) + .map((f) => f.slice(0, -4)), +) + +export const hasIconSlug = (slug) => typeof slug === 'string' && SLUGS.has(slug) + +export const resolveIconPath = (slug) => { + if (!hasIconSlug(slug)) return null + return join(ICONS_DIR, `${slug}.svg`) +} + +/** Sorted slug list for the CC dropdown. */ +export const listIconSlugs = () => Array.from(SLUGS).sort() diff --git a/backend/test/routes/client.payment.routes.test.js b/backend/test/routes/client.payment.routes.test.js index 2503163..04472fa 100644 --- a/backend/test/routes/client.payment.routes.test.js +++ b/backend/test/routes/client.payment.routes.test.js @@ -123,6 +123,65 @@ describe('POST /api/client/payment-requests', () => { expect(confirmed.confirmed_at).toBeTruthy() }) + it('rejects with INVALID_PAYMENT_AMOUNT when amount falls below the method min', async () => { + // Seed a low-min method and price the request below it. + const sql = db() + const [g] = await sql` + INSERT INTO payment_method_groups (name, display_order, is_active) + VALUES ('TestGroup-min', 99, true) RETURNING id + ` + await sql` + INSERT INTO payment_methods (group_id, display_name, payment_code, display_order, + icon, min_amount, max_amount, is_active) + VALUES (${g.id}, 'TestVA', 'TEST_VA', 0, null, 50000, null, true) + ` + // Bust the catalog cache so the new method is visible. + const { invalidatePaymentCatalog } = await import('../../src/services/payment-catalog.service.js') + await invalidatePaymentCatalog() + + // Eligible discount path puts the price at 2000 — well below TEST_VA's 50000 min. + const res = await app.inject({ + method: 'POST', + url: '/api/client/payment-requests', + headers: authHeader(token), + payload: { duration_minutes: 12, method: 'TEST_VA' }, + }) + + expect(res.statusCode).toBe(422) + const body = res.json() + expect(body.error.code).toBe('INVALID_PAYMENT_AMOUNT') + expect(body.error.details.min_amount).toBe(50000) + expect(body.error.details.amount).toBe(2000) + }) + + it('rejects with INVALID_PAYMENT_AMOUNT when amount exceeds the method max', async () => { + const sql = db() + const [g] = await sql` + INSERT INTO payment_method_groups (name, display_order, is_active) + VALUES ('TestGroup-max', 99, true) RETURNING id + ` + await sql` + INSERT INTO payment_methods (group_id, display_name, payment_code, display_order, + icon, min_amount, max_amount, is_active) + VALUES (${g.id}, 'TestWallet', 'TEST_W', 0, null, null, 1000, true) + ` + const { invalidatePaymentCatalog } = await import('../../src/services/payment-catalog.service.js') + await invalidatePaymentCatalog() + + // Discounted 12-min = 2000 IDR, above the 1000 max. + const res = await app.inject({ + method: 'POST', + url: '/api/client/payment-requests', + headers: authHeader(token), + payload: { duration_minutes: 12, method: 'TEST_W' }, + }) + + expect(res.statusCode).toBe(422) + const body = res.json() + expect(body.error.code).toBe('INVALID_PAYMENT_AMOUNT') + expect(body.error.details.max_amount).toBe(1000) + }) + it('call-mode payment session uses the call tier price group', async () => { // 20-minute call tier in Phase 4 = 17000 IDR. const res = await app.inject({ diff --git a/backend/test/routes/shared.payment-icons.routes.test.js b/backend/test/routes/shared.payment-icons.routes.test.js new file mode 100644 index 0000000..da25f09 --- /dev/null +++ b/backend/test/routes/shared.payment-icons.routes.test.js @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' + +vi.mock('../../src/plugins/websocket.js', () => ({ + sendToUser: vi.fn(() => false), + sendToSessionParticipant: vi.fn(() => false), + registerWebSocketPlugin: vi.fn(async () => {}), + registerWebSocketRoute: vi.fn(), + isUserOnlineWs: vi.fn(() => false), + getSessionConnections: vi.fn(() => ({})), +})) +vi.mock('../../src/services/notification.service.js', () => ({ + sendPushNotification: vi.fn(async () => true), + registerDeviceToken: vi.fn(async () => {}), +})) + +const { buildPublic } = await import('../helpers/server.js') + +describe('GET /assets/payment-icons/:slug.svg', () => { + let app + + beforeAll(async () => { + app = await buildPublic() + }) + + afterAll(async () => { + await app.close() + }) + + it('serves a known idn-finlogos slug with svg content-type and immutable cache', async () => { + // 'qris' is one of our seeded slugs and is shipped by idn-finlogos. + const res = await app.inject({ method: 'GET', url: '/assets/payment-icons/qris.svg' }) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toBe('image/svg+xml') + expect(res.headers['cache-control']).toBe('public, max-age=31536000, immutable') + expect(res.body).toMatch(/^<\?xml|^ { + const res = await app.inject({ + method: 'GET', + url: '/assets/payment-icons/definitely-not-a-real-bank.svg', + }) + expect(res.statusCode).toBe(404) + const body = JSON.parse(res.body) + expect(body.error.code).toBe('NOT_FOUND') + }) + + it('404s on slug with path-traversal characters', async () => { + // Fastify normalises `..` in the URL, so this resolves to the parent + // route; we still expect a 404 (no SVG matches) — but the SLUG_RE guard + // is what would catch an unencoded attempt if a router ever let one + // through, so we cover its negative case here. + const res = await app.inject({ + method: 'GET', + url: '/assets/payment-icons/UPPER.svg', + }) + expect(res.statusCode).toBe(404) + }) +}) diff --git a/backend/test/services/payment-catalog.service.test.js b/backend/test/services/payment-catalog.service.test.js index 2df9814..ed003de 100644 --- a/backend/test/services/payment-catalog.service.test.js +++ b/backend/test/services/payment-catalog.service.test.js @@ -44,10 +44,19 @@ const insertGroup = async ({ name, order = 0, active = true }) => { return row.id } -const insertMethod = async ({ groupId, code, display = null, order = 0, icon = null, active = true }) => { +const insertMethod = async ({ + groupId, code, display = null, order = 0, icon = null, + minAmount = null, maxAmount = 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}) + INSERT INTO payment_methods ( + group_id, display_name, payment_code, display_order, icon, + min_amount, max_amount, is_active + ) + VALUES ( + ${groupId}, ${display ?? code}, ${code}, ${order}, ${icon}, + ${minAmount}, ${maxAmount}, ${active} + ) RETURNING id ` return row.id @@ -116,6 +125,41 @@ describe('payment-catalog.service', () => { expect(groups.map((g) => g.name)).toEqual(['E-Wallet']) }) + it('parses icon as comma-separated slug list into icon_urls (relative /assets paths)', async () => { + const g = await insertGroup({ name: 'E-Wallet' }) + await insertMethod({ groupId: g, code: 'OVO', icon: 'ovo-new', order: 0 }) + await insertMethod({ groupId: g, code: 'CARDS', icon: 'visa,mastercard,jcb', order: 1 }) + await insertMethod({ groupId: g, code: 'NOICON', icon: null, order: 2 }) + + await invalidatePaymentCatalog() + const { groups } = await getCatalogForApp() + const methods = groups[0].methods + expect(methods[0].icon_urls).toEqual(['/assets/payment-icons/ovo-new.svg']) + expect(methods[1].icon_urls).toEqual([ + '/assets/payment-icons/visa.svg', + '/assets/payment-icons/mastercard.svg', + '/assets/payment-icons/jcb.svg', + ]) + expect(methods[2].icon_urls).toEqual([]) + }) + + it('emits min_amount + max_amount per method (null when not set)', async () => { + const g = await insertGroup({ name: 'E-Wallet' }) + await insertMethod({ groupId: g, code: 'OVO', minAmount: 10000, maxAmount: 10000000, order: 0 }) + await insertMethod({ groupId: g, code: 'QRIS', minAmount: 1, maxAmount: null, order: 1 }) + await insertMethod({ groupId: g, code: 'BCA_VA', minAmount: null, maxAmount: null, order: 2 }) + + await invalidatePaymentCatalog() + const { groups } = await getCatalogForApp() + const methods = groups[0].methods + expect(methods[0].min_amount).toBe(10000) + expect(methods[0].max_amount).toBe(10000000) + expect(methods[1].min_amount).toBe(1) + expect(methods[1].max_amount).toBeNull() + expect(methods[2].min_amount).toBeNull() + expect(methods[2].max_amount).toBeNull() + }) + 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' }) diff --git a/client_app/android/app/src/main/AndroidManifest.xml b/client_app/android/app/src/main/AndroidManifest.xml index 61c2fbf..affab01 100644 --- a/client_app/android/app/src/main/AndroidManifest.xml +++ b/client_app/android/app/src/main/AndroidManifest.xml @@ -28,6 +28,19 @@ + + + + + + + diff --git a/client_app/assets/payment_icons/NOTICE_IDN_FINLOGOS.txt b/client_app/assets/payment_icons/NOTICE_IDN_FINLOGOS.txt deleted file mode 100644 index a64be70..0000000 --- a/client_app/assets/payment_icons/NOTICE_IDN_FINLOGOS.txt +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 368da09..0000000 --- a/client_app/assets/payment_icons/README.md +++ /dev/null @@ -1,79 +0,0 @@ -# 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 deleted file mode 100644 index 4ddf06e..0000000 --- a/client_app/assets/payment_icons/bca.svg +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/client_app/assets/payment_icons/bni.svg b/client_app/assets/payment_icons/bni.svg deleted file mode 100644 index ac4ed9a..0000000 --- a/client_app/assets/payment_icons/bni.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/client_app/assets/payment_icons/bri.svg b/client_app/assets/payment_icons/bri.svg deleted file mode 100644 index 54e712c..0000000 --- a/client_app/assets/payment_icons/bri.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/client_app/assets/payment_icons/dana.svg b/client_app/assets/payment_icons/dana.svg deleted file mode 100644 index c1a9052..0000000 --- a/client_app/assets/payment_icons/dana.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/client_app/assets/payment_icons/gopay.svg b/client_app/assets/payment_icons/gopay.svg deleted file mode 100644 index c90a98b..0000000 --- a/client_app/assets/payment_icons/gopay.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/client_app/assets/payment_icons/mandiri.svg b/client_app/assets/payment_icons/mandiri.svg deleted file mode 100644 index f0f5e85..0000000 --- a/client_app/assets/payment_icons/mandiri.svg +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/client_app/assets/payment_icons/ovo.svg b/client_app/assets/payment_icons/ovo.svg deleted file mode 100644 index 38d40af..0000000 --- a/client_app/assets/payment_icons/ovo.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/client_app/assets/payment_icons/permata.svg b/client_app/assets/payment_icons/permata.svg deleted file mode 100644 index f984955..0000000 --- a/client_app/assets/payment_icons/permata.svg +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/client_app/assets/payment_icons/qris.svg b/client_app/assets/payment_icons/qris.svg deleted file mode 100644 index 26f8b52..0000000 --- a/client_app/assets/payment_icons/qris.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/client_app/assets/payment_icons/shopeepay.svg b/client_app/assets/payment_icons/shopeepay.svg deleted file mode 100644 index cc6ce93..0000000 --- a/client_app/assets/payment_icons/shopeepay.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/client_app/ios/Runner/Info.plist b/client_app/ios/Runner/Info.plist index ebf31f6..fea3909 100644 --- a/client_app/ios/Runner/Info.plist +++ b/client_app/ios/Runner/Info.plist @@ -80,5 +80,19 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + + CFBundleURLTypes + + + CFBundleURLName + com.mybestie + CFBundleURLSchemes + + halobestie + + + 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 376ec56..a356025 100644 --- a/client_app/lib/features/payment/screens/payment_method_screen.dart +++ b/client_app/lib/features/payment/screens/payment_method_screen.dart @@ -86,6 +86,12 @@ class _PaymentMethodScreenState extends ConsumerState { String _humanError(DioException e) { final code = e.response?.data?['error']?['code'] as String?; final status = e.response?.statusCode; + if (code == 'INVALID_PAYMENT_AMOUNT') { + // Server confirms the picker should have caught this — most likely a + // stale catalog. The picker's tile subtitle already explains; we just + // need to nudge the user to pick a different method. + return 'Metode pembayaran tidak cocok untuk nominal ini. Pilih metode lain.'; + } if (status == 422 || code == 'VALIDATION_ERROR' || code == 'INVALID_TIER') { return 'Pilihan durasi tidak valid.'; } @@ -204,6 +210,7 @@ class _PaymentMethodScreenState extends ConsumerState { group: g, expanded: _expandedGroupIds.contains(g.id), selectedCode: _selectedCode, + amount: amount, onToggle: () => setState(() { if (!_expandedGroupIds.add(g.id)) { _expandedGroupIds.remove(g.id); @@ -277,6 +284,7 @@ class _GroupSection extends StatelessWidget { final PaymentMethodGroup group; final bool expanded; final String? selectedCode; + final int amount; final VoidCallback onToggle; final ValueChanged onSelect; @@ -284,6 +292,7 @@ class _GroupSection extends StatelessWidget { required this.group, required this.expanded, required this.selectedCode, + required this.amount, required this.onToggle, required this.onSelect, }); @@ -334,13 +343,17 @@ class _GroupSection extends StatelessWidget { 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(), + children: group.methods.map((m) { + final disabledReason = m.disabledReason(amount); + return _MethodTile( + method: m, + selected: selectedCode == m.paymentCode, + disabledReason: disabledReason, + onTap: disabledReason == null + ? () => onSelect(m.paymentCode) + : null, + ); + }).toList(), ), crossFadeState: expanded ? CrossFadeState.showSecond : CrossFadeState.showFirst, @@ -351,90 +364,136 @@ class _GroupSection extends StatelessWidget { } } +/// Visual container for one or more brand-mark icons on a payment-method tile. +/// +/// Single-icon: 40×40 box with the icon at 22px. Multi-icon (e.g. credit-card +/// row showing Visa + Mastercard + JCB): box widens to fit, icons render at +/// 18px with 3px gaps. Empty list: placeholder via [PaymentIcon] in the box. +class _MethodIconBox extends StatelessWidget { + final List iconUrls; + const _MethodIconBox({required this.iconUrls}); + + @override + Widget build(BuildContext context) { + final multi = iconUrls.length > 1; + final iconSize = multi ? 18.0 : 22.0; + final children = iconUrls.isEmpty + ? [ + PaymentIcon(iconUrl: null, size: iconSize, color: HaloTokens.brandDark), + ] + : [ + for (var i = 0; i < iconUrls.length; i++) ...[ + if (i > 0) const SizedBox(width: 3), + PaymentIcon(iconUrl: iconUrls[i], size: iconSize, color: HaloTokens.brandDark), + ], + ]; + return Container( + height: 40, + constraints: BoxConstraints(minWidth: multi ? 0 : 40), + padding: EdgeInsets.symmetric(horizontal: multi ? 6 : 0), + decoration: BoxDecoration( + color: HaloTokens.surface, + borderRadius: HaloRadius.md, + border: Border.all(color: HaloTokens.border), + ), + alignment: Alignment.center, + child: Row(mainAxisSize: MainAxisSize.min, children: children), + ); + } +} + class _MethodTile extends StatelessWidget { final PaymentMethodEntry method; final bool selected; - final VoidCallback onTap; + final String? disabledReason; + final VoidCallback? onTap; const _MethodTile({ required this.method, required this.selected, + required this.disabledReason, required this.onTap, }); @override Widget build(BuildContext context) { + final disabled = disabledReason != null; return Padding( padding: const EdgeInsets.only(bottom: HaloSpacing.s8), - child: Material( - color: selected ? HaloTokens.brandSofter : HaloTokens.surface, - borderRadius: HaloRadius.lg, - child: InkWell( + child: Opacity( + opacity: disabled ? 0.5 : 1.0, + child: Material( + color: selected ? HaloTokens.brandSofter : HaloTokens.surface, borderRadius: HaloRadius.lg, - onTap: onTap, - child: AnimatedContainer( - duration: HaloMotion.fast, - padding: const EdgeInsets.all(HaloSpacing.s12), - decoration: BoxDecoration( - border: Border.all( - color: selected ? HaloTokens.brand : HaloTokens.border, - width: selected ? 2 : 1, + child: InkWell( + borderRadius: HaloRadius.lg, + onTap: onTap, + child: AnimatedContainer( + duration: HaloMotion.fast, + padding: const EdgeInsets.all(HaloSpacing.s12), + decoration: BoxDecoration( + border: Border.all( + color: selected ? HaloTokens.brand : HaloTokens.border, + width: selected ? 2 : 1, + ), + borderRadius: HaloRadius.lg, ), - borderRadius: HaloRadius.lg, - ), - child: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: HaloTokens.surface, - borderRadius: HaloRadius.md, - border: Border.all(color: HaloTokens.border), - ), - alignment: Alignment.center, - child: PaymentIcon( - slug: method.icon, - size: 22, - color: HaloTokens.brandDark, - ), - ), - const SizedBox(width: HaloSpacing.s12), - Expanded( - child: Text( - method.displayName, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: HaloTokens.ink, - ), - ), - ), - Container( - width: 20, - height: 20, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: selected ? HaloTokens.brand : HaloTokens.border, - width: 2, - ), - color: selected ? HaloTokens.brand : HaloTokens.surface, - ), - child: selected - ? Center( - child: Container( - width: 8, - height: 8, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.white, + child: Row( + children: [ + _MethodIconBox(iconUrls: method.iconUrls), + const SizedBox(width: HaloSpacing.s12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + method.displayName, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: HaloTokens.ink, + ), + ), + if (disabled) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + disabledReason!, + style: const TextStyle( + fontSize: 11.5, + color: HaloTokens.inkMuted, + ), ), ), - ) - : null, - ), - ], + ], + ), + ), + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: selected ? HaloTokens.brand : HaloTokens.border, + width: 2, + ), + color: selected ? HaloTokens.brand : HaloTokens.surface, + ), + child: selected + ? Center( + child: Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + ), + ), + ) + : null, + ), + ], + ), ), ), ), diff --git a/client_app/lib/features/payment/state/payment_catalog_provider.dart b/client_app/lib/features/payment/state/payment_catalog_provider.dart index 4164862..06db942 100644 --- a/client_app/lib/features/payment/state/payment_catalog_provider.dart +++ b/client_app/lib/features/payment/state/payment_catalog_provider.dart @@ -2,18 +2,58 @@ 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`). +/// +/// `iconUrls` is the backend-resolved list of brand-SVG URLs for this method. +/// Relative paths (e.g. `/assets/payment-icons/qris.svg`) are prepended with +/// `ApiClient.baseUrl` by [PaymentIcon]. Composite tiles (e.g. credit card +/// showing Visa + Mastercard + JCB) carry multiple entries. Empty list = no +/// icon configured → the row renders the bundled placeholder. +/// +/// `minAmount` / `maxAmount` are inclusive Rupiah bounds, either nullable +/// (null = no bound). The picker greys out methods the current bill misses, +/// and the backend enforces the same bounds defensively with +/// `INVALID_PAYMENT_AMOUNT`. class PaymentMethodEntry { final String id; final String paymentCode; final String displayName; - final String? icon; + final List iconUrls; + final int? minAmount; + final int? maxAmount; const PaymentMethodEntry({ required this.id, required this.paymentCode, required this.displayName, - this.icon, + this.iconUrls = const [], + this.minAmount, + this.maxAmount, }); + + /// `null` when the method is usable at [amount]; otherwise a short reason + /// suitable as a tile subtitle (Indonesian, brand voice). + String? disabledReason(int amount) { + if (minAmount != null && amount < minAmount!) { + return 'min ${_rp(minAmount!)}'; + } + if (maxAmount != null && amount > maxAmount!) { + return 'maks ${_rp(maxAmount!)}'; + } + return null; + } +} + +String _rp(int n) { + // Rp 10.000 (Indonesian thousand-separator). Matches the picker's other + // amount formatting via `formatRupiah` in core/constants.dart, kept local + // to avoid pulling that dependency into the catalog model. + final s = n.toString(); + final buf = StringBuffer('Rp '); + for (var i = 0; i < s.length; i++) { + if (i > 0 && (s.length - i) % 3 == 0) buf.write('.'); + buf.write(s[i]); + } + return buf.toString(); } /// One group in the payment-method catalog (server-side: @@ -50,11 +90,13 @@ const _kFallbackGroup = PaymentMethodGroup( id: 'fallback-paling-cepat', name: 'Paling Cepat', methods: [ + // Fallback path renders the bundled placeholder (iconUrls empty) — the + // catalog endpoint being unreachable usually means the icon endpoint is + // too, so deferring to the placeholder is the safest signal. PaymentMethodEntry( id: 'fallback-qris', paymentCode: 'QRIS', displayName: 'QRIS', - icon: 'qris', ), ], ); @@ -74,11 +116,14 @@ final paymentCatalogProvider = FutureProvider((ref) async { final gm = g as Map; final methods = (gm['methods'] as List? ?? const []).map((m) { final mm = m as Map; + final iconUrlsRaw = mm['icon_urls'] as List? ?? const []; return PaymentMethodEntry( id: mm['id'] as String, paymentCode: mm['payment_code'] as String, displayName: mm['display_name'] as String, - icon: mm['icon'] as String?, + iconUrls: iconUrlsRaw.cast(), + minAmount: (mm['min_amount'] as num?)?.toInt(), + maxAmount: (mm['max_amount'] as num?)?.toInt(), ); }).toList(growable: false); return PaymentMethodGroup( diff --git a/client_app/lib/features/payment/widgets/payment_icon.dart b/client_app/lib/features/payment/widgets/payment_icon.dart index 8d3d1fc..20b85b1 100644 --- a/client_app/lib/features/payment/widgets/payment_icon.dart +++ b/client_app/lib/features/payment/widgets/payment_icon.dart @@ -1,56 +1,72 @@ import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import '../../../core/api/api_client.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. +/// Renders a payment-method brand mark fetched from the backend. /// -/// 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. +/// `iconUrl` comes from `payment_methods.icon_url` on the catalog response. +/// Relative paths (the common case — `/assets/payment-icons/.svg`) are +/// resolved against [ApiClient.baseUrl]. Absolute URLs (operator override) +/// are used as-is. +/// +/// First fetch hits the network; the file is then persisted to disk by +/// [DefaultCacheManager] (30-day idle LRU) and served locally on subsequent +/// renders. While the cache lookup is in flight or when [iconUrl] is null, +/// the bundled placeholder is shown so the picker never displays a spinner. class PaymentIcon extends StatelessWidget { - final String? slug; + final String? iconUrl; final double size; final Color color; const PaymentIcon({ super.key, - required this.slug, + required this.iconUrl, this.size = 24, this.color = HaloTokens.brandDark, }); + String? get _resolvedUrl { + final raw = iconUrl; + if (raw == null || raw.isEmpty) return null; + if (raw.startsWith('http://') || raw.startsWith('https://')) return raw; + return '${ApiClient.baseUrl}$raw'; + } + @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'; + final url = _resolvedUrl; + if (url == null) return _placeholder(); - // 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), + return FutureBuilder( + future: DefaultCacheManager().getFileFromCache(url), + builder: (context, cachedSnap) { + final cached = cachedSnap.data?.file; + if (cached != null) { + return SvgPicture.file(cached, width: size, height: size); + } + // Cache miss: render placeholder while the download lands, then swap + // in. We fire the download in the same FutureBuilder body so the next + // rebuild picks up the freshly-cached file. + return FutureBuilder( + future: DefaultCacheManager().getSingleFile(url), + builder: (context, snap) { + if (snap.hasData) { + return SvgPicture.file(snap.data!, width: size, height: size); + } + return _placeholder(); + }, + ); + }, ); } + + Widget _placeholder() => SvgPicture.asset( + 'assets/payment_icons/placeholder.svg', + width: size, + height: size, + colorFilter: ColorFilter.mode(color, BlendMode.srcIn), + ); } diff --git a/client_app/macos/Flutter/GeneratedPluginRegistrant.swift b/client_app/macos/Flutter/GeneratedPluginRegistrant.swift index 7e6f540..61cff3f 100644 --- a/client_app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/client_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -12,6 +12,7 @@ import flutter_secure_storage_macos import google_sign_in_ios import shared_preferences_foundation import sign_in_with_apple +import sqflite_darwin import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -22,5 +23,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/client_app/pubspec.lock b/client_app/pubspec.lock index f455d23..9b42a5d 100644 --- a/client_app/pubspec.lock +++ b/client_app/pubspec.lock @@ -358,6 +358,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_cache_manager: + dependency: "direct main" + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" flutter_hooks: dependency: "direct main" description: @@ -1132,6 +1140,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a" + url: "https://pub.dev" + source: hosted + version: "2.4.2+1" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" + url: "https://pub.dev" + source: hosted + version: "2.4.2+3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465" + url: "https://pub.dev" + source: hosted + version: "2.5.8" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" stack_trace: dependency: transitive description: @@ -1172,6 +1220,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" + url: "https://pub.dev" + source: hosted + version: "3.4.0+1" term_glyph: dependency: transitive description: @@ -1389,5 +1445,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.3 <4.0.0" + dart: ">=3.11.0 <4.0.0" flutter: ">=3.38.4" diff --git a/client_app/pubspec.yaml b/client_app/pubspec.yaml index a511c57..4c81b63 100644 --- a/client_app/pubspec.yaml +++ b/client_app/pubspec.yaml @@ -42,12 +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. + # Payment method icons (Phase 5.x catalog). Source-of-truth is the backend + # `/assets/payment-icons/.svg` endpoint, which wraps the `idn-finlogos` + # npm package. Mobile only bundles `placeholder.svg` as the first-launch / + # offline fallback; everything else is fetched on demand and persisted via + # `flutter_cache_manager` for far-future reuse. flutter_svg: ^2.0.10+1 + flutter_cache_manager: ^3.3.1 # OS notification permission — used by the post-payment notif gate # (Phase 4 Stage 4) and the home banner. diff --git a/control_center/index.html b/control_center/index.html index e0ae928..a249a87 100644 --- a/control_center/index.html +++ b/control_center/index.html @@ -4,6 +4,9 @@ Halo Bestie Control Center + + +
diff --git a/control_center/src/assets/logo.png b/control_center/src/assets/logo.png new file mode 100755 index 0000000..51570ab Binary files /dev/null and b/control_center/src/assets/logo.png differ diff --git a/control_center/src/components/Layout.jsx b/control_center/src/components/Layout.jsx index d176ca6..b51c454 100644 --- a/control_center/src/components/Layout.jsx +++ b/control_center/src/components/Layout.jsx @@ -2,6 +2,18 @@ import { useState } from 'react' import { Outlet, NavLink } from 'react-router-dom' import { useAuth } from '../core/auth/AuthContext' import { apiClient } from '../core/api/api-client' +import HBLogo from './ui/HBLogo' + +const NAV_ITEMS = [ + { to: '/dashboard', label: 'Dashboard' }, + { to: '/mitras', label: 'Mitra' }, + { to: '/sessions', label: 'Sesi' }, + { to: '/failed-pairings', label: 'Failed Pairings' }, + { to: '/users', label: 'Users' }, + { to: '/mitra-activity', label: 'Aktivitas Mitra' }, + { to: '/payment-catalog', label: 'Payment Catalog' }, + { to: '/settings', label: 'Settings' }, +] const PasswordChangeForm = ({ onDone }) => { const [current, setCurrent] = useState('') @@ -34,18 +46,33 @@ const PasswordChangeForm = ({ onDone }) => { } return ( -
- setCurrent(e.target.value)} required - style={{ display: 'block', width: '100%', marginBottom: 6 }} /> - setNext(e.target.value)} required minLength={8} - style={{ display: 'block', width: '100%', marginBottom: 6 }} /> - {error &&

{error}

} - {success &&

Password berhasil diubah.

} -
- - + + setCurrent(e.target.value)} + required + style={{ marginBottom: 8 }} + /> + setNext(e.target.value)} + required + minLength={8} + style={{ marginBottom: 8 }} + /> + {error &&

{error}

} + {success &&

Password berhasil diubah.

} +
+ +
) @@ -56,27 +83,111 @@ export default function Layout() { const [showPwForm, setShowPwForm] = useState(false) return ( -
-