Lewati ke konten utama

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 merchants row = 1 operational outlet
  • 1 user (owner) logs in via WhatsApp OTP, accesses all merchant data via mobile
  • The v_user_active_merchant view resolves 1 user → 1 merchant context
  • Low traffic volume, polling-based, mobile-first UX

Those assumptions break when an enterprise merchant comes in:

AspectUMKM (existing Tier 4)Enterprise (PT Phoenixdart)
Outlet scale150+
Device scale1–2100+
Users per merchant1 ownerHierarchical: owner / regional manager / outlet supervisor / viewer / accounting / BI engineer
Access patternMobile, ad-hoc, per-transaction checkWeb dashboard + BI/ERP API integration, scheduled reporting
AuthPer-person WhatsApp OTPEmail-password with MFA, or SSO into the corporate ERP
Query scopeWHERE merchant_id = $selfMulti-outlet drill-down + region filter + device-level aggregation
Volume< 100 req/h1000+ req/h, bursty during daily closing (22:00–24:00)
ComplianceTLS + JWT is enoughFull audit log, granular RBAC, data retention policy

If we force enterprise to use the Tier 4 mobile infrastructure, we get:

  1. UX collapse: a list of 100 devices in a single Flutter mobile ListView, slow scrolling, limited filters
  2. Flat permission: a Jabodetabek regional manager cannot be limited to just 5 outlets because merchant_users.membership_role is flat per merchant without an outlet_id column
  3. Performance regression: the getMerchantTransactionSummary query 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)
  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:

TierConsumerAuthAccess
1. PSP Internalpayment.kesles.comHMAC psp.api_keys or partner.api_credentials (psp_role=internal)/api/psp/v1/* + /api/psp/v1/payment-events/*
2. PSP ExternalBank/Acquirer (Mandiri, BRI, BNI)HMAC partner.api_credentials (psp_role=bank)/api/psp/v1/* filtered by nmid_assigned_bank_code
3. Partner non-PSPJoob, Ampersand, commercial resellerHMAC partner.api_credentials (psp_role=none, partner_type=reseller/agency/etc.)/api/partner/v1/* with merchantScopeFragment 4-mode
4. Merchant UMKMUMKM end users via mobile + a thin dashboardBearer JWT user (iam.users)/merchant/* with merchant_id = $self filter
5. Merchant Enterprise (new)Phoenixdart, etc. — multi-role staff + ERP integrationBearer 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):

OptionRiskResult
A. Full Kesles-hosted dashboardHigh Kesles team effort (~3 months, 2 devs), risk of over-engineering if there are only 1–2 enterprise merchantsSkip initially
B. API-only, Phoenixdart builds its own dashboardAssumes Phoenixdart has an engineering team — not yet confirmedSkip if Phoenixdart asks for a ready-made dashboard
C. Phased hybridMedium Kesles team effort (~6 weeks), modular scale-upDefault choice

Option C is adopted because:

  1. 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).
  2. 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.
  3. 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:

MethodPathPurpose
GET/enterprise/v1/merchantSelf merchant detail (resolved from credential)
GET/enterprise/v1/outletsList all merchant outlets
GET/enterprise/v1/outlets/{outlet_id}Outlet detail
GET/enterprise/v1/devicesList all merchant devices (terminals)
GET/enterprise/v1/devices/{device_id}Device detail + status
GET/enterprise/v1/transactionsList transactions with date+outlet+device filter, cursor pagination
GET/enterprise/v1/transactions/summaryAggregated summary per range, group by outlet/device/day
GET/enterprise/v1/settlementsSettlement batches per range

Auth pattern (mirrors /api/psp/v1/*):

  1. Middleware enterpriseAuthMiddleware — uses the pspAuthMiddleware HMAC style, but looks up in partner.api_credentials JOIN partner.partners filtered by partner_type='enterprise_merchant'
  2. Attach EnterpriseAuthContext{ MerchantID: <linked_merchant_id> } to the request context
  3. The handler automatically injects WHERE merchant_id = $1 (similar to mandatoryBankCodeFromContext)

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

  1. Sales+CS confirm Phoenixdart commitment + sign NDA
  2. Kesles operator via the Dashboard: edit the Phoenixdart merchant → set merchant_tier='enterprise'
  3. Operator creates a partner.partners row: partner_type='enterprise_merchant', linked_merchant_id=<phoenixdart-merchant-id>, psp_role='none', access_scope_mode='global' (or dummy because the scope is implicit through linked_merchant_id)
  4. Operator creates a credential in Master Data > Partner > Credential (the new UI patch already generates HMAC)
  5. Hand off credential_key + plaintext credential_secret to the Phoenixdart PIC via a secure channel (signal, encrypted email)
  6. 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 contract
  • merchant_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:

ModulePageData source
DashboardKPI today (gross, count, settlement pending) + 30-day chart/enterprise/v1/transactions/summary
OutletsList + detail + per-outlet performance/enterprise/v1/outlets, /enterprise/v1/outlets/{id}/performance
DevicesList + status (online/offline/error)/enterprise/v1/devices + heartbeat polling
TransactionsFilter+search+CSV export/enterprise/v1/transactions
SettlementMonthly 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 grants
  • accounting — 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_deliveries to 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)

  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.
  2. Brand requirement — does Phoenixdart want the dashboard to display "Phoenixdart Reports" or is "Kesles Enterprise" enough? Whitelabel = additional theming effort.
  3. 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.
  4. 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)?
  5. SLA/uptime requirement — does Phoenixdart have an MSA with SLA? Does this tier need redundancy different from Tier 4 mobile?
  6. 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.
  7. 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?
  8. Pricing tier — does the enterprise merchant pay a monthly fee for dashboard + API access? Has implications for the billing schema.

8. Decision summary

QuestionDefault decisionStatus
Tier 5 or extend Tier 4?New Tier 5 (5-tier classification)Confirmed in this plan
ApproachOption C Phased hybridPending sales validation
Phase 1 authReuse HMAC partner.api_credentialsPending
Schema isolationmerchant.merchants.merchant_tier flag + partner.partners.linked_merchant_idDraft
Tier 5 RBACPer-outlet via merchant.outlet_user_grants tablePhase 2
Dashboard techFlutter webPhase 2
Phoenixdart onboarding planPhase 1 first (API-only)Pending

9. Critical files to be created/changed

FileActionPhase
merchant_database/db_kesles_merchant/migrations/099_merchant_enterprise.sqlCREATE1
merchant_database/db_kesles_merchant/migrations/100_outlet_user_grants.sqlCREATE2
merchant_core_api/internal/httpapi/routes_enterprise.goCREATE1
merchant_core_api/internal/enterprise/store.goCREATE1
merchant_core_api/internal/enterprise/scope.goCREATE2
services/dashboard_api/internal/app/psp_auth_middleware.goEDIT — add enterprise_merchant lookup path1
apps/merchant_enterprise_dashboard/CREATE (folder)2
merchant_docs/api_docs/internal/docs/api-internal/enterprise-api-contract.mdCREATE1
merchant_docs/api_docs/public/docs/api-docs/enterprise-api.mdCREATE1
merchant_docs/api_docs/internal/static/postman/kesles-enterprise.postman_collection.jsonCREATE1

10. Verification

Phase 1 acceptance criteria:

  1. Smoke test — Phoenixdart credential resolves to the correct linked_merchant_id in the middleware
  2. Scope test — credential for merchant A hits /enterprise/v1/transactions → 0 rows from merchant B
  3. Performance test — dummy merchant with 100k transactions, /enterprise/v1/transactions/summary?from=2026-01-01&to=2026-04-30 p95 < 200ms
  4. Negative test — credential partner_type='reseller' hits /enterprise/v1/* → 403 (tier mismatch)
  5. Audit log — every enterprise request is recorded in partner.api_audit_logs (existing table)

Phase 2 acceptance criteria:

  1. RBAC test — a regional manager granted 5 outlets logs in to the dashboard and can only filter to those 5 outlets
  2. Permission propagation — revoking an outlet grant → user loses access in < 60 seconds (via cache invalidation)
  3. UX test — a list of 100 devices with filter+sort responsive in < 1s loading time
  4. Cross-tenant test — a merchant A operator cannot enumerate merchant B users via the dashboard

11. Risk register

RiskMitigation
Phoenixdart asks for the dashboard before Phase 2 finishesPhase 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 prodThe 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 requestAudit log retention policy 5 years (UU PDP). Erase user data via soft delete + tombstone, do not hard delete (audit integrity).
Whitelabel cost overrunPhase 2 default = no whitelabel. Whitelabel becomes a paid feature in Phase 3.

12. Next steps

  1. Sales/CS validate the §7 questions with Phoenixdart PIC
  2. Confirm the final Phase 1 scope (8 endpoints or a subset)
  3. Create migration 099 + skeleton handler routes_enterprise.go
  4. Issue the first credential to the Phoenixdart QA team for integration testing
  5. Kick off Phase 2 dashboard if Phoenixdart confirms interest in a hosted dashboard