Merchant Enterprise — Architecture Plan
Status: Draft as of 2026-04-30 — awaiting validation from sales/CS pre Phoenixdart kickoff.
Driver: PT Phoenixdart Sport Indonesia (50+ outlets, 100+ devices) — the first merchant whose scale exceeds the single-outlet UMKM assumption.
1. Context
The existing Tier 4 (Merchant) is designed for a single-outlet single-owner UMKM:
- 1
merchantsrow = 1 operational outlet - 1 user (owner) logs in via WhatsApp OTP, accesses all merchant data via mobile
- The
v_user_active_merchantview resolves 1 user → 1 merchant context - Low traffic volume, polling-based, mobile-first UX
Those assumptions break when an enterprise merchant comes in:
| Aspect | UMKM (existing Tier 4) | Enterprise (PT Phoenixdart) |
|---|---|---|
| Outlet scale | 1 | 50+ |
| Device scale | 1–2 | 100+ |
| Users per merchant | 1 owner | Hierarchical: owner / regional manager / outlet supervisor / viewer / accounting / BI engineer |
| Access pattern | Mobile, ad-hoc, per-transaction check | Web dashboard + BI/ERP API integration, scheduled reporting |
| Auth | Per-person WhatsApp OTP | Email-password with MFA, or SSO into the corporate ERP |
| Query scope | WHERE merchant_id = $self | Multi-outlet drill-down + region filter + device-level aggregation |
| Volume | < 100 req/h | 1000+ req/h, bursty during daily closing (22:00–24:00) |
| Compliance | TLS + JWT is enough | Full audit log, granular RBAC, data retention policy |
If we force enterprise to use the Tier 4 mobile infrastructure, we get:
- UX collapse: a list of 100 devices in a single Flutter mobile ListView, slow scrolling, limited filters
- Flat permission: a Jabodetabek regional manager cannot be limited to just 5 outlets because
merchant_users.membership_roleis flat per merchant without an outlet_id column - Performance regression: the
getMerchantTransactionSummaryquery for a merchant with 1 million transactions via plain view #2 (v_merchant_daily_summary) can be slow (see baseline in database/schema/views.md §4.4) - ERP integration: Phoenixdart has an Oracle ERP that pushes a receipt batch at 03:00. Tier 4 has no HMAC API path for programmatic consumers.
2. 5-tier classification (revision)
Previously the project used a 4-tier classification (PSP Internal / PSP External / Partner / Merchant). This plan adds Tier 5 as a sub-segment of Merchant:
| Tier | Consumer | Auth | Access |
|---|---|---|---|
| 1. PSP Internal | payment.kesles.com | HMAC psp.api_keys or partner.api_credentials (psp_role=internal) | /api/psp/v1/* + /api/psp/v1/payment-events/* |
| 2. PSP External | Bank/Acquirer (Mandiri, BRI, BNI) | HMAC partner.api_credentials (psp_role=bank) | /api/psp/v1/* filtered by nmid_assigned_bank_code |
| 3. Partner non-PSP | Joob, Ampersand, commercial reseller | HMAC partner.api_credentials (psp_role=none, partner_type=reseller/agency/etc.) | /api/partner/v1/* with merchantScopeFragment 4-mode |
| 4. Merchant UMKM | UMKM end users via mobile + a thin dashboard | Bearer JWT user (iam.users) | /merchant/* with merchant_id = $self filter |
| 5. Merchant Enterprise (new) | Phoenixdart, etc. — multi-role staff + ERP integration | Bearer JWT user (web dashboard) or HMAC partner.api_credentials (programmatic ERP/BI integration) | /enterprise/v1/* with multi-outlet RBAC |
Tier 5 uses both auth schemes depending on the consumer:
- Human staff accessing the dashboard → JWT
- Phoenixdart ERP/BI pulling data → HMAC partner credential (a new category
partner_type='enterprise_merchant')
3. Architecture decision: Option C — Phased hybrid
3 options were considered (see the original discussion):
| Option | Risk | Result |
|---|---|---|
| A. Full Kesles-hosted dashboard | High Kesles team effort (~3 months, 2 devs), risk of over-engineering if there are only 1–2 enterprise merchants | Skip initially |
| B. API-only, Phoenixdart builds its own dashboard | Assumes Phoenixdart has an engineering team — not yet confirmed | Skip if Phoenixdart asks for a ready-made dashboard |
| C. Phased hybrid | Medium Kesles team effort (~6 weeks), modular scale-up | Default choice |
Option C is adopted because:
- Phase 1 (API-only) is enough for Phoenixdart if they have internal BI — give them credentials + docs, they pull data into their own warehouse. Minimal Kesles effort (~3 weeks).
- Phase 2 (Hosted dashboard) is added if Phoenixdart asks for a ready-to-use UX for non-technical staff. Can be deferred until demand is validated.
- Phase 3 (Operational features) — scheduled reports, anomaly detection, outbound webhooks to ERP — incremental.
Each phase is standalone usable — Phoenixdart can onboard at the end of Phase 1 without waiting for Phase 2.
4. Phase 1 — API foundation (3 weeks, 1 dev)
4.1 Schema changes
New migration merchant_database/db_kesles_merchant/migrations/099_merchant_enterprise.sql:
begin;
-- Tier classification per merchant (default 'umkm')
alter table merchant.merchants
add column if not exists merchant_tier varchar(20) not null default 'umkm';
alter table merchant.merchants
drop constraint if exists chk_merchants_merchant_tier;
alter table merchant.merchants
add constraint chk_merchants_merchant_tier
check (merchant_tier in ('umkm', 'enterprise'));
create index if not exists idx_merchants_tier
on merchant.merchants (merchant_tier)
where deleted_at is null and merchant_tier <> 'umkm';
comment on column merchant.merchants.merchant_tier is
'umkm = single-outlet (Tier 4); enterprise = multi-outlet, multi-user, ERP-integrated (Tier 5).';
-- Extend partner_type enum to accommodate enterprise merchant credentials
-- (partner.api_credentials will be used for ERP/BI auth)
alter table partner.partners
drop constraint if exists chk_partners_partner_type;
alter table partner.partners
add constraint chk_partners_partner_type
check (partner_type in (
'reseller', 'agency', 'affiliate', 'community', 'sales',
'enterprise_merchant', -- NEW
'other'
));
-- Mapping enterprise merchant ↔ partner credential row (1:1 logical)
-- Reuse partner.partners to store the credential, but link back to
-- merchant.merchants so the handler scope filter knows the merchant.
alter table partner.partners
add column if not exists linked_merchant_id uuid null
references merchant.merchants(id) on delete set null;
alter table partner.partners
drop constraint if exists chk_partners_linked_merchant;
alter table partner.partners
add constraint chk_partners_linked_merchant
check (
(partner_type = 'enterprise_merchant' and linked_merchant_id is not null)
or (partner_type <> 'enterprise_merchant' and linked_merchant_id is null)
);
create unique index if not exists uk_partners_enterprise_merchant
on partner.partners (linked_merchant_id)
where partner_type = 'enterprise_merchant' and deleted_at is null;
commit;
4.2 New backend endpoints
File: merchant_core_api/internal/httpapi/routes_enterprise.go
Initial 8 read-only endpoints:
| Method | Path | Purpose |
|---|---|---|
| GET | /enterprise/v1/merchant | Self merchant detail (resolved from credential) |
| GET | /enterprise/v1/outlets | List all merchant outlets |
| GET | /enterprise/v1/outlets/{outlet_id} | Outlet detail |
| GET | /enterprise/v1/devices | List all merchant devices (terminals) |
| GET | /enterprise/v1/devices/{device_id} | Device detail + status |
| GET | /enterprise/v1/transactions | List transactions with date+outlet+device filter, cursor pagination |
| GET | /enterprise/v1/transactions/summary | Aggregated summary per range, group by outlet/device/day |
| GET | /enterprise/v1/settlements | Settlement batches per range |
Auth pattern (mirrors /api/psp/v1/*):
- Middleware
enterpriseAuthMiddleware— uses thepspAuthMiddlewareHMAC style, but looks up inpartner.api_credentialsJOINpartner.partnersfiltered bypartner_type='enterprise_merchant' - Attach
EnterpriseAuthContext{ MerchantID: <linked_merchant_id> }to the request context - The handler automatically injects
WHERE merchant_id = $1(similar tomandatoryBankCodeFromContext)
4.3 New view
-- Optional: outlet rollup view so the /transactions/summary endpoint does
-- not full-table aggregate per request when the merchant has millions of transactions.
create or replace view merchant.v_outlet_daily_summary as
select
t.merchant_id,
t.outlet_id,
(t.transaction_at at time zone 'Asia/Jakarta')::date as transaction_date_wib,
count(*) filter (where t.transaction_status='success') as success_count,
coalesce(sum(t.gross_amount) filter (where t.transaction_status='success'),0) as gross_amount,
coalesce(sum(t.mdr_fee_amount) filter (where t.transaction_status='success'),0) as mdr_fee_amount
from merchant.transactions t
where t.outlet_id is not null
group by t.merchant_id, t.outlet_id, (t.transaction_at at time zone 'Asia/Jakarta')::date;
If performance becomes a problem, promote to an MV refreshed daily (same pattern as v_partner_merchant_performance_30d mig 050).
4.4 Phoenixdart Phase 1 onboarding flow
- Sales+CS confirm Phoenixdart commitment + sign NDA
- Kesles operator via the Dashboard: edit the Phoenixdart merchant → set
merchant_tier='enterprise' - Operator creates a
partner.partnersrow:partner_type='enterprise_merchant',linked_merchant_id=<phoenixdart-merchant-id>,psp_role='none',access_scope_mode='global'(or dummy because the scope is implicit throughlinked_merchant_id) - Operator creates a credential in Master Data > Partner > Credential (the new UI patch already generates HMAC)
- Hand off
credential_key+ plaintextcredential_secretto the Phoenixdart PIC via a secure channel (signal, encrypted email) - Phoenixdart signs a request with HMAC, hits
/enterprise/v1/*
4.5 Test plan
- Middleware unit test: a credential with
partner_type='enterprise_merchant'resolves to the correct MerchantID - Integration test: insert a dummy enterprise merchant, hit
/enterprise/v1/transactions→ only returns that merchant's transactions - Negative test: a tier reseller credential hits
/enterprise/v1/*→ 403 - Performance test: a dummy merchant with 100k transactions, summary endpoint p95 < 200ms
4.6 Documentation
merchant_docs/api_docs/internal/docs/api-internal/enterprise-api-contract.md— internal contractmerchant_docs/api_docs/public/docs/api-docs/enterprise-api.md— public spec for Phoenixdart developers- Postman collection at
merchant_docs/api_docs/internal/static/postman/kesles-enterprise.postman_collection.json
5. Phase 2 — Hosted dashboard (4–8 weeks, 2 devs)
5.1 New Flutter web app
New folder: apps/merchant_enterprise_dashboard/ (structure mirrors apps/merchant_dashboard/).
Tech stack:
- Flutter web (Dart) — consistent with the existing mobile/dashboard ecosystem
- Routing: GoRouter
- State: Riverpod (same as the existing dashboard)
- Auth: email-password with MFA (TOTP or WhatsApp OTP fallback)
Initial modules:
| Module | Page | Data source |
|---|---|---|
| Dashboard | KPI today (gross, count, settlement pending) + 30-day chart | /enterprise/v1/transactions/summary |
| Outlets | List + detail + per-outlet performance | /enterprise/v1/outlets, /enterprise/v1/outlets/{id}/performance |
| Devices | List + status (online/offline/error) | /enterprise/v1/devices + heartbeat polling |
| Transactions | Filter+search+CSV export | /enterprise/v1/transactions |
| Settlement | Monthly batch | /enterprise/v1/settlements |
| Users (admin) | RBAC mgmt: invite user, assign role per outlet | (new admin endpoints) |
5.2 RBAC schema
create table if not exists merchant.outlet_user_grants (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references iam.users(id) on delete cascade,
merchant_id uuid not null references merchant.merchants(id) on delete cascade,
outlet_id uuid null references merchant.outlets(id) on delete cascade,
role varchar(30) not null, -- 'super_admin','regional_manager','outlet_supervisor','viewer','accounting'
granted_by_user_id uuid references iam.users(id) on delete set null,
granted_at timestamptz not null default now(),
revoked_at timestamptz,
created_at timestamptz not null default now(),
constraint chk_outlet_user_grants_role
check (role in ('super_admin','regional_manager','outlet_supervisor','viewer','accounting'))
);
create unique index if not exists uk_outlet_user_grants_active
on merchant.outlet_user_grants (user_id, merchant_id, coalesce(outlet_id::text, ''))
where revoked_at is null;
Role semantics:
super_admin— full access to all outlets, can manage users (max 1 per merchant)regional_manager— access to a subset of outlets (multiple outlet_id grants)outlet_supervisor— access to 1 outlet (single outlet_id grant)viewer— read-only, scope same as the grantsaccounting— read-only + access to settlement & cross-outlet report exports
outlet_id IS NULL = the grant applies to all outlets in that merchant (used by super_admin).
5.3 Outlet scope resolver helper
Mirrors the mandatoryBankCodeFromContext pattern:
// internal/enterprise/scope.go
func (s *Store) ResolveOutletScope(ctx context.Context, userID, merchantID string) (OutletScope, error) {
// returns OutletIDs []string, IsAll bool, Role string, MerchantID string
}
// SQL fragment helper:
// IsAll=true → "TRUE"
// IsAll=false → "outlet_id = ANY($N::uuid[])"
Each enterprise dashboard endpoint injects the scope fragment so a Jabodetabek regional manager can only see 5 outlets in the region.
6. Phase 3 — Operational features (8+ weeks)
Incremental, depends on demand:
- Scheduled email reports — daily settlement summary PDF, sent at 06:00 to the accounting email
- Anomaly detection — device offline > 30 min, transaction drop > 30% MoM → email/Slack alerts to the regional manager
- Outbound webhook to Phoenixdart ERP — realtime event push when settlement is final, expand
partner.webhook_deliveriesto merchant domain events - Public API for accounting integrations — endpoints in Xero/Accurate/Jurnal-compatible format
- Enterprise-only mobile app — a new Flutter mobile app for outlet supervisors (daily sales check, device status, notifications); reuses
/enterprise/v1/*endpoints
7. Open questions (must be answered pre-Phase 1)
- Phoenixdart engineering capacity — do they have internal developers who can consume an HMAC API? Yes → Phase 1 is enough. No → jump to Phase 1 + Phase 2 in parallel.
- Brand requirement — does Phoenixdart want the dashboard to display "Phoenixdart Reports" or is "Kesles Enterprise" enough? Whitelabel = additional theming effort.
- Compliance — may merchant data be pulled out via API to a Phoenixdart server, or must it stay in the Kesles cloud? Affects retention + replication strategy.
- Dashboard onboarding timeline — minimum acceptable ETA from sales? Phase 1+2 in parallel (~10 weeks) or staged (Phase 1 ~3 weeks, Phase 2 +6 weeks)?
- SLA/uptime requirement — does Phoenixdart have an MSA with SLA? Does this tier need redundancy different from Tier 4 mobile?
- User identity — do Phoenixdart staff log in with a corporate email + new password, or SSO into the corporate ERP (Okta, Azure AD, Google Workspace)? SSO adds ~2 weeks effort + SAML/OIDC infrastructure.
- Small scope first or full? — Phase 1 minimum 8 endpoints, or just 3 most-used endpoints (transactions list + summary + outlets) so Phoenixdart starts integrating quickly?
- Pricing tier — does the enterprise merchant pay a monthly fee for dashboard + API access? Has implications for the billing schema.
8. Decision summary
| Question | Default decision | Status |
|---|---|---|
| Tier 5 or extend Tier 4? | New Tier 5 (5-tier classification) | Confirmed in this plan |
| Approach | Option C Phased hybrid | Pending sales validation |
| Phase 1 auth | Reuse HMAC partner.api_credentials | Pending |
| Schema isolation | merchant.merchants.merchant_tier flag + partner.partners.linked_merchant_id | Draft |
| Tier 5 RBAC | Per-outlet via merchant.outlet_user_grants table | Phase 2 |
| Dashboard tech | Flutter web | Phase 2 |
| Phoenixdart onboarding plan | Phase 1 first (API-only) | Pending |
9. Critical files to be created/changed
| File | Action | Phase |
|---|---|---|
merchant_database/db_kesles_merchant/migrations/099_merchant_enterprise.sql | CREATE | 1 |
merchant_database/db_kesles_merchant/migrations/100_outlet_user_grants.sql | CREATE | 2 |
merchant_core_api/internal/httpapi/routes_enterprise.go | CREATE | 1 |
merchant_core_api/internal/enterprise/store.go | CREATE | 1 |
merchant_core_api/internal/enterprise/scope.go | CREATE | 2 |
services/dashboard_api/internal/app/psp_auth_middleware.go | EDIT — add enterprise_merchant lookup path | 1 |
apps/merchant_enterprise_dashboard/ | CREATE (folder) | 2 |
merchant_docs/api_docs/internal/docs/api-internal/enterprise-api-contract.md | CREATE | 1 |
merchant_docs/api_docs/public/docs/api-docs/enterprise-api.md | CREATE | 1 |
merchant_docs/api_docs/internal/static/postman/kesles-enterprise.postman_collection.json | CREATE | 1 |
10. Verification
Phase 1 acceptance criteria:
- Smoke test — Phoenixdart credential resolves to the correct
linked_merchant_idin the middleware - Scope test — credential for merchant A hits
/enterprise/v1/transactions→ 0 rows from merchant B - Performance test — dummy merchant with 100k transactions,
/enterprise/v1/transactions/summary?from=2026-01-01&to=2026-04-30p95 < 200ms - Negative test — credential
partner_type='reseller'hits/enterprise/v1/*→ 403 (tier mismatch) - Audit log — every enterprise request is recorded in
partner.api_audit_logs(existing table)
Phase 2 acceptance criteria:
- RBAC test — a regional manager granted 5 outlets logs in to the dashboard and can only filter to those 5 outlets
- Permission propagation — revoking an outlet grant → user loses access in < 60 seconds (via cache invalidation)
- UX test — a list of 100 devices with filter+sort responsive in < 1s loading time
- Cross-tenant test — a merchant A operator cannot enumerate merchant B users via the dashboard
11. Risk register
| Risk | Mitigation |
|---|---|
| Phoenixdart asks for the dashboard before Phase 2 finishes | Phase 1 is already enough for BI integration. Negotiate to use the existing Kesles dashboard first (read-only) while Phase 2 progresses. |
The 1:1 linked_merchant_id schema is limiting if Phoenixdart brings a group structure (parent + child company) | Future migration: change to N:N via a partner.merchant_links table + role hierarchy. Does not break Phase 1 because the schema is additive. |
| Slow summary endpoint performance in prod | The v_outlet_daily_summary view can be promoted to an MV (refreshed every hour). Plus column-store as a long-term solution if >10M transactions. |
| Phoenixdart compliance (PDP-UU 2024) — right-to-deletion request | Audit log retention policy 5 years (UU PDP). Erase user data via soft delete + tombstone, do not hard delete (audit integrity). |
| Whitelabel cost overrun | Phase 2 default = no whitelabel. Whitelabel becomes a paid feature in Phase 3. |
12. Next steps
- Sales/CS validate the §7 questions with Phoenixdart PIC
- Confirm the final Phase 1 scope (8 endpoints or a subset)
- Create migration 099 + skeleton handler
routes_enterprise.go - Issue the first credential to the Phoenixdart QA team for integration testing
- Kick off Phase 2 dashboard if Phoenixdart confirms interest in a hosted dashboard