Lewati ke konten utama

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:


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 statusSource tableWhen it happens
pendingmerchant_registration_requests.statusA new request is submitted by the user
pending_reviewmrr.statusA Kesles admin taps "Start Review" in the dashboard
pending_acquirermrr.statusThe reviewer submits to the acquirer bank
acquirer_approvedmrr.statusThe bank replies with approval, awaiting Kesles finalization
activemerchant.merchants.statusThe reviewer taps "Approve" → the merchants row is created
inactive / draft / deletedmerchant.merchants.statusEdge cases (not yet registered / soft delete)
suspendedmerchant.merchants.statusAdmin suspends the merchant
terminatedmerchant.merchants.statusMerchant permanently deactivated by admin

Data source (2026-06-03): merchant.merchants tetap di db_kesles_merchant (merchant_core_api). merchant_registration_requests juga ada di db_kesles_merchant. Tidak ada yang dipindah ke auth_service — hanya iam.* / 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 statusmerchantStatusLabel (literal)merchantActionButtonLabelmerchantActivationProgressProfile header pill
'' / draft / inactive"Belum Aktif"Aktifkan20%"Belum Aktif"
deleted"Tidak Ada"Aktifkan20%"Tidak Ada"
pendingDiajukanDiajukan60%"Diajukan"
pending_reviewDiajukanDiajukan75%"Diajukan"
pending_acquirerDiajukanDiajukan85%"Diajukan"
acquirer_approvedDiajukanDiajukan95%"Diajukan"
activeAktif100%"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
activeactive
suspendedsuspended
terminateddeactivated (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)

HelperReturnsConsumers
isMerchantActiveStatus(status)bool true if activeGating 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 terminatedStatic terminated page routing
shouldOpenMerchantRegistration(status)bool true if empty/draft/inactive/deletedRouting for Order tile/Merchant Account → Intro
shouldOpenMerchantProfile(status)bool true if activeRouting → Profile page
merchantStatusLabel(status)String label (ID)Pill text
merchantActionButtonLabel(status)"Aktifkan" / "Diajukan" / ""CTA button
merchantActivationProgress(status)0.0..1.0Progress bar in the banner
merchantActivationHeadline / Description / StoreTitle / StoreSubtitleLocalized strings per sub-stageHome banner copy
merchantInsightTitle / SubtitleLocalized strings per sub-stageInsight 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 (MerchantAccountMenuPage for 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 typeBackend triggerMobile reaction
merchant_status_pending_reviewdashboard_merchant_registration.go (start-review handler) → forwards via dispatchMerchantEventAsyncRe-fetch profile → banner updates to "Diajukan" 75%
merchant_status_pending_acquirerinternal_merchant_handlers.go:186 in core_api (called by dashboard) → fireMerchantEventAsync directBanner updates to 85%
merchant_status_acquirer_approvedinternal_merchant_handlers.go:284 in core_api (acquirer-response success)Banner updates to 95%
merchant_status_rejectedsame handler as above (acquirer-response rejected)Rejection message + revise button shown
merchant_status_activeinternal_merchant_handlers.go:357 in core_api (approve) atau NMID status event active dari PSPBanner disappears, "Start accepting payments" appears
merchant_status_suspendedAdmin suspend atau NMID status event suspended dari PSPSuspended page shown
merchant_status_terminatedAdmin terminate atau NMID status event terminated dari PSPTerminated page shown, no CTA
order_createdcore_api merchant_sales_order_create_handler.go:236Device Monitor refresh (via profile tick)
order_accepteddashboard sales-order transition (accept)Device Monitor: "Pesanan diterima — menunggu pembayaran"
order_paiddashboard mark-paid (dashboard_sales_orders.go:634)Device Monitor: "Pembayaran diterima"
order_processingdashboard sales-order transition (start processing) or shipping createDevice Monitor: "Pesanan sedang dikemas"
order_ready_to_shipdashboard create-shippingDevice Monitor: "Siap dikirim"
order_in_transitdashboard dispatch-shippingDevice Monitor: "Dalam pengiriman"
order_deliveredcore_api merchant_shipping_handler.go:468Device Monitor: "Sudah diterima — pairing"
order_completeddashboard sales transition (complete) or core_api shipping cascade (merchant_shipping_handler.go:470)Device Monitor: "Pesanan selesai"
order_cancelleddashboard 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 at merchant_event_messages.go — adding a new event type must be mirrored in _isMerchantStatusOrOrderEvent in push_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());
}
LayerWhat it doesFailure 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

SurfaceFile
Activation bannerhome_merchant_activation_banner.dart
Header cardhome_header_card.dart
Insight bannerhome_insight_banner.dart
Balance cardhome_balance_card.dart
Home dashboard pagehome_dashboard_page.dart
Device monitordevice_monitor_page.dart
Transactions dailytransactions_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:69 listens to profileRefreshTick, 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 dedicated OrderListRefreshService).
  • Background FCM replay-on-resume. The handler firebaseMessagingBackgroundHandler is 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 manual getProfile(). Add a "drain pending status events from inbox" step in app-resume.
  • terminated mobile stateMerchantTerminatedPage belum ada di codebase. Perlu dibuat dengan routing sesuai §3. isMerchantTerminatedStatus helper juga belum ada — tambah ke shared/merchant_status.dart.

Resolved (no fix needed): DB constraint chk_merchants_status (mig 008) only allows draft / pending_review / active / inactive / suspended / deleted. Audited 2026-05-14: no code path sets merchants.status to pending_acquirer or acquirer_approved — those values live exclusively in merchant_registration_requests.status (mig 090). Constraint coverage is fine as-is. terminated value perlu ditambahkan ke constraint saat implementasi dimulai.