Mobile Merchant Status — UX Mapping & Auto-Refresh
Reference document mapping DB status → mobile label + how mobile auto-refreshes when the status changes on the backend (FCM event dispatcher).
See also:
merchant-registration-current-flow.md— end-to-end registration flow.merchant-activation-funnel-plan.md— 8-stage funnel analytics.transaction-notification-flow.md— FCM for transactions (separate from status events).
1. Full status taxonomy
1.1 DB enum
merchant.merchants.status (constraint chk_merchants_status) +
registration status (mig 090)
used by handlers but not yet enforced in the constraint:
| DB status | Source table | When it happens |
|---|---|---|
pending | merchant_registration_requests.status | A new request is submitted by the user |
pending_review | mrr.status | A Kesles admin taps "Start Review" in the dashboard |
pending_acquirer | mrr.status | The reviewer submits to the acquirer bank |
acquirer_approved | mrr.status | The bank replies with approval, awaiting Kesles finalization |
active | merchant.merchants.status | The reviewer taps "Approve" → the merchants row is created |
inactive / draft / deleted | merchant.merchants.status | Edge cases (not yet registered / soft delete) |
suspended | merchant.merchants.status | Admin suspends the merchant |
terminated | merchant.merchants.status | Merchant permanently deactivated by admin |
Data source (2026-06-03):
merchant.merchantstetap didb_kesles_merchant(merchant_core_api).merchant_registration_requestsjuga ada didb_kesles_merchant. Tidak ada yang dipindah ke auth_service — hanyaiam.*/auth.*/kyc.*yang extraction.
1.2 Mapping to mobile labels
Implementation in shared/merchant_status.dart.
Source language: the helpers currently return Indonesian literal strings (no i18n layer yet). The English labels in the table below are spec descriptions; the actual code returns "Diajukan" / "Aktifkan" / "Aktif" / "Belum Aktif" / "Suspend" / "Tidak Ada" / "Nonaktif".
| DB status | merchantStatusLabel (literal) | merchantActionButtonLabel | merchantActivationProgress | Profile header pill |
|---|---|---|---|---|
'' / draft / inactive | "Belum Aktif" | Aktifkan | 20% | "Belum Aktif" |
deleted | "Tidak Ada" | Aktifkan | 20% | "Tidak Ada" |
pending | Diajukan | Diajukan | 60% | "Diajukan" |
pending_review | Diajukan | Diajukan | 75% | "Diajukan" |
pending_acquirer | Diajukan | Diajukan | 85% | "Diajukan" |
acquirer_approved | Diajukan | Diajukan | 95% | "Diajukan" |
active | Aktif | — | 100% | "Aktif" |
suspended | "Suspend" | Aktifkan (default) | 20% | "Suspend" |
terminated | "Nonaktif" | — (no action) | 0% | "Nonaktif" |
UX decision (locked): the 4 pending_* sub-stages all show a single label "Diajukan" on mobile. The operator sees the sub-stage detail in the dashboard, the merchant only needs to know that their request is being processed. The incremental progress bar (60% → 75% → 85% → 95%) still visualizes the progress even though the label is the same.
terminated behavior: merchant yang terminated tidak bisa lagi melakukan aktivasi baru. Button CTA tidak tampil. User diarahkan ke halaman status statis dengan pesan "Akun merchant Anda telah dinonaktifkan. Hubungi support Kesles untuk informasi lebih lanjut."
1.3 NMID status → merchant status mapping
Saat PSP (payment.kesles.com) mengirim nmid-status-changed event, backend memetakan status NMID ke status merchant:
| NMID status (dari PSP) | Merchant status yang di-set |
|---|---|
active | active |
suspended | suspended |
terminated | deactivated (di-write sebagai terminated di DB) |
Event ini ditangani di payment_service (internal/payment/transport/http/handlers_psp_webhook.go via processNMIDStatusChanged + mapNMIDStatusToMerchantStatus). payment_service lalu memanggil core_api POST /internal/merchant/status (handleUpdateMerchantStatus) untuk meng-update status merchant.
1.4 Headline + description per sub-stage
Even though the label says "Submitted", the headline and description in the Home banner + insight banner differ per sub-stage — taken from the helpers:
merchantActivationHeadline(status)→ "Merchant request sent" / "Merchant under review" / "Request forwarded to the acquirer bank" / "Bank has approved — awaiting final activation" / "Merchant activation complete".merchantActivationDescription(status)— extra explanation per sub-stage.merchantActivationStoreTitle/Subtitle— for the store-style banner.merchantInsightTitle/Subtitle— for the insight banner.
2. Public helpers (mobile)
| Helper | Returns | Consumers |
|---|---|---|
isMerchantActiveStatus(status) | bool true if active | Gating for Order tile, Profile page, Catalog page |
isMerchantPendingStatus(status) | bool true for the 4 pending_* | Conditional banner rendering |
isMerchantTerminatedStatus(status) (planned, belum ada di shared/merchant_status.dart — lihat §5) | bool true if terminated | Static terminated page routing |
shouldOpenMerchantRegistration(status) | bool true if empty/draft/inactive/deleted | Routing for Order tile/Merchant Account → Intro |
shouldOpenMerchantProfile(status) | bool true if active | Routing → Profile page |
merchantStatusLabel(status) | String label (ID) | Pill text |
merchantActionButtonLabel(status) | "Aktifkan" / "Diajukan" / "" | CTA button |
merchantActivationProgress(status) | 0.0..1.0 | Progress bar in the banner |
merchantActivationHeadline / Description / StoreTitle / StoreSubtitle | Localized strings per sub-stage | Home banner copy |
merchantInsightTitle / Subtitle | Localized strings per sub-stage | Insight banner copy |
3. Routing pattern when status is non-active
This pattern is consistent across every mobile surface. Callers just use the standard helpers:
final Widget page;
if (isMerchantActiveStatus(status)) {
page = const MerchantDeviceCatalogPage(); // active → main destination
} else if (isMerchantTerminatedStatus(status)) {
page = const MerchantTerminatedPage(); // terminated → static info page
} else if (shouldOpenMerchantRegistration(status)) {
page = const MerchantActivationIntroPage(); // not registered → registration
} else {
page = const MerchantAccountStatusPage(); // submitted/suspended → status page
}
Surfaces following this pattern:
- Profile → Order tile (
profile_page.dart) - Profile → Merchant Account tile + "Submitted" pill (
MerchantAccountMenuPagefor the menu hub) - Edit Profile → Merchant Account CTA
- Home activation banner
- Home insight banner
- Transactions / Reports / Device tab header
- Device Monitor → "Order QRIS Plus" banner
- Kasir (
kasir_page.dart) — guard di_loadData(), tampilkan state "Aktivasi Merchant Diperlukan" sebelum fetch API POS Lite (2026-06-11)
4. Status auto-refresh: FCM event dispatcher
4.1 Problem (before implementation)
Mobile only re-fetched the profile when the user manually navigated to a page that triggered getProfile() (Profile page, Edit Profile, etc.). As a result, when an admin tapped "Start Review" / "Approve" in the dashboard, mobile didn't know — the Home banner kept showing the "Activate" button until the user manually went to Profile and back.
4.2 Solution: a single-source dispatcher endpoint
Backend handlers that change status now fire FCM via the helper
fireMerchantEventAsync (core_api) or forward to the
/internal/notifications/merchant-event endpoint (dashboard_api → core_api).
| Event type | Backend trigger | Mobile reaction |
|---|---|---|
merchant_status_pending_review | dashboard_merchant_registration.go (start-review handler) → forwards via dispatchMerchantEventAsync | Re-fetch profile → banner updates to "Diajukan" 75% |
merchant_status_pending_acquirer | internal_merchant_handlers.go:186 in core_api (called by dashboard) → fireMerchantEventAsync direct | Banner updates to 85% |
merchant_status_acquirer_approved | internal_merchant_handlers.go:284 in core_api (acquirer-response success) | Banner updates to 95% |
merchant_status_rejected | same handler as above (acquirer-response rejected) | Rejection message + revise button shown |
merchant_status_active | internal_merchant_handlers.go:357 in core_api (approve) atau NMID status event active dari PSP | Banner disappears, "Start accepting payments" appears |
merchant_status_suspended | Admin suspend atau NMID status event suspended dari PSP | Suspended page shown |
merchant_status_terminated | Admin terminate atau NMID status event terminated dari PSP | Terminated page shown, no CTA |
order_created | core_api merchant_sales_order_create_handler.go:236 | Device Monitor refresh (via profile tick) |
order_accepted | dashboard sales-order transition (accept) | Device Monitor: "Pesanan diterima — menunggu pembayaran" |
order_paid | dashboard mark-paid (dashboard_sales_orders.go:634) | Device Monitor: "Pembayaran diterima" |
order_processing | dashboard sales-order transition (start processing) or shipping create | Device Monitor: "Pesanan sedang dikemas" |
order_ready_to_ship | dashboard create-shipping | Device Monitor: "Siap dikirim" |
order_in_transit | dashboard dispatch-shipping | Device Monitor: "Dalam pengiriman" |
order_delivered | core_api merchant_shipping_handler.go:468 | Device Monitor: "Sudah diterima — pairing" |
order_completed | dashboard sales transition (complete) or core_api shipping cascade (merchant_shipping_handler.go:470) | Device Monitor: "Pesanan selesai" |
order_cancelled | dashboard sales transition (cancel) or core_api auto-cancel worker (sales_order_auto_cancel_worker.go:141) | Device Monitor: "Pesanan dibatalkan" |
Naming convention:
<domain>_<state>snake_case.merchant_status_*triggers profile re-fetch;order_*triggers profile + cache invalidation across Transactions/Home/Profile/Device. Single source of truth atmerchant_event_messages.go— adding a new event type must be mirrored in_isMerchantStatusOrOrderEventinpush_notification_service.dart.
4.3 Backend dispatcher
File: merchant_core_api/internal/httpapi/internal_merchant_event_handler.go
POST /internal/notifications/merchant-event
Headers: X-Internal-API-Key
Body: { "merchant_id": "...", "user_id": "...", "event_type": "...", "reference_id": "..." }
Title/body are resolved from event_type in merchant_event_messages.go — the single source of truth for FCM messages.
Token lookup priority: merchant_id (via merchant_users JOIN) → fallback to user_id (via notification.fcm_push_tokens di db_kesles_merchant_notification direct). The fallback is important for the pre-active state (registration phase) where the merchant_users row does not exist yet.
4.4 Caller pattern
Core_api handler (which has direct access to pushClient):
s.fireMerchantEventAsync(merchantID, userID, "merchant_status_active", refID)
Dashboard_api handler (forward via HTTP):
s.dispatchMerchantEventAsync(merchantID, userID, "order_paid", orderNumber)
One of (merchantID or userID) must be non-empty. For pre-active flows, leave merchantID empty and fill userID from mrr.created_by_user_id.
4.5 Mobile listener — three-layer flow
File: push_notification_service.dart (_foregroundMessageSubscription callback, ~lines 104–159).
When an FCM with merchant-status or order type arrives, the listener does three things in order — each layer fixes a real failure mode the previous layer doesn't cover:
final String type = (payload['type'] ?? '').trim();
if (type == 'transaction_info') {
unawaited(TransactionsApiService.clearCache());
unawaited(HomeDashboardApiService.clearCache());
TransactionRefreshService.notifyTransactionUpdated();
} else if (_isMerchantStatusOrOrderEvent(type)) {
// 1) Infer & persist status NOW — don't wait for the API roundtrip.
final String? inferredStatus = _inferMerchantStatusFromType(type);
if (inferredStatus != null) {
unawaited(_authSessionService.saveProfile(merchantStatus: inferredStatus));
}
// 2) Invalidate every cache that embeds merchant_status.
unawaited(TransactionsApiService.clearCache());
unawaited(HomeDashboardApiService.clearCache());
unawaited(ProfileApiService.clearCache());
unawaited(DeviceApiService.clearCache());
// 3) Re-fetch profile as source of truth for other fields.
unawaited(_refreshProfileFromServer());
}
| Layer | What it does | Failure mode it prevents |
|---|---|---|
1. Status inference (_inferMerchantStatusFromType) | Maps FCM type → merchant_status, persists to local session immediately, fires profileRefreshTick. | Race: backend dispatches FCM faster than the DB commit, so getProfile() sees stale status and the banner stays at the old progress until the user kills the app. |
| 2. Cache invalidation (Transactions / Home / Profile / Device) | Clears 5-minute SharedPreferences cache that embeds merchant_status. | Summary screens (today_summary, balance_total, terminal status) keep showing stale "Rp 0" or "Belum Aktif" for up to 5 min even though the banner already shows 100%. |
3. Server refresh (_refreshProfileFromServer) | Re-fetches /profile, calls saveProfile(notify=true) → profileRefreshTick++. Empty-status guard at line 302 skips overwrite when server returns merchantStatus = "" (replica lag / partial response) — prevents the banner from collapsing 100% → 20% on a transient miss. | Other fields (merchant_name, contact_email, NMID, reviewed_at) won't refresh; also self-corrects if FCM type ever mismatches DB. |
profileRefreshTick.value++ fans out to every listener (banner, header, balance, device monitor, transactions header, etc.) which then re-render from cache + re-fetch their own data.
4.6 List of profileRefreshTick listeners
| Surface | File |
|---|---|
| Activation banner | home_merchant_activation_banner.dart |
| Header card | home_header_card.dart |
| Insight banner | home_insight_banner.dart |
| Balance card | home_balance_card.dart |
| Home dashboard page | home_dashboard_page.dart |
| Device monitor | device_monitor_page.dart |
| Transactions daily | transactions_daily_page.dart |
5. Still TODO
- Refresh the order list in Device Monitor when an
order_*event arrives. Today only the profile tick fires —device_monitor_page.dart:69listens toprofileRefreshTick, which causes a re-render but does not explicitly re-fetch the order list. Add a second listener in Device Monitor that reacts to the FCM type directly (or call a dedicatedOrderListRefreshService). - Background FCM replay-on-resume. The handler
firebaseMessagingBackgroundHandleris registered (✅) and persists payloads to the inbox so the Notification Center stays in sync. Missing piece: replay status events when the app resumes — currently the foreground 3-layer flow only fires for FCMs received while the app is active, so a status change that arrives while the app is killed is invisible until the next manualgetProfile(). Add a "drain pending status events from inbox" step in app-resume. -
terminatedmobile state —MerchantTerminatedPagebelum ada di codebase. Perlu dibuat dengan routing sesuai §3.isMerchantTerminatedStatushelper juga belum ada — tambah keshared/merchant_status.dart.
Resolved (no fix needed): DB constraint
chk_merchants_status(mig 008) only allowsdraft / pending_review / active / inactive / suspended / deleted. Audited 2026-05-14: no code path setsmerchants.statustopending_acquireroracquirer_approved— those values live exclusively inmerchant_registration_requests.status(mig 090). Constraint coverage is fine as-is.terminatedvalue perlu ditambahkan ke constraint saat implementasi dimulai.