Lewati ke konten utama

Partner Access Scope & Revenue Share — Plan

Status: Draft — awaiting approval before Ship A kicks off.

Status (2026-06-23): shipped. Ships A–F are live in partner_service (port 8086, DB db_kesles_merchant_partner, schema partner). The original design narrative below is retained; code/path references have been updated to the extracted service. Owner: Partner Platform team. Related tables: partner.partners, partner.api_credentials, partner.merchant_access, partner.merchant_consents, partner.api_audit_logs, merchant.merchants. Related docs: partner-v1-api-proposal.md, purchasing-sales-refactor-plan.md.

1. Context & Problem

1.1 Business background

Kesles Merchant works with several partner types in parallel that have very different characteristics, not a single model:

  1. Merchant acquisition partners (reseller, agency, affiliate, community, sales) — they look for new merchants and earn commission from the transactions of merchants they refer.
  2. Finance / underwriter partners (bank, lender, scoring bureau) — they need merchant transaction data for credit analysis before they extend financing.
  3. Ecosystem fee-share partners (QRIS operator, settlement gateway, integration partner) — they share MDR/service-fee revenue based on a contract, and their visibility may cover one ecosystem, several ecosystems, or a combination.
  4. Strategic stakeholders (venture capital, holding shareholders, regulators OJK/BI, internal BI / data science) — they need an aggregated view or full read-only for governance/monitoring/investment decisions.

Currently the partner API stack does not differentiate between these four categories. They all use the same authorization path, and the only visibility filter is HasMerchantAccess(partner_id, merchant_id) checked against partner.merchant_access. The effect is that whenever a new partner type with a different need shows up, engineers must add an if-else branch in the handler or a hardcoded whitelist — not scalable.

1.2 Current system state (baseline 2026-04-22)

Existing data:

  • merchant.partners — partner master data, the partner_type column with enum reseller | agency | affiliate | community | sales | other. bank | finance_lender | scoring_bureau | venture_capital | shareholder | regulator do not exist yet.
  • merchants.referral_partner_id + merchants.referral_code (migration 022) — every merchant records which partner acquired it. Already accurate for 412+ production merchants.
  • partner.api_credentials — per-credential allowed_scopes (jsonb) which controls OAuth-style scopes (transaction.read, scoring.read, merchant.read, risk-summary.read). The API scope (what may be read) exists, but the merchant visibility scope (which merchant may be read) does not.
  • partner.merchant_access (migration 031) — explicit grant table per (partner_id, merchant_id, access_type). Has an access_type column ('referral', 'consent', 'manual_grant').
  • partner.merchant_consents — consent per merchant with consent_scope and consent_expires_at. Suitable for bank/finance.
  • partner.api_audit_logs — log of every partner request (method, path, status, scope_used). Does not yet record the visibility mode or the merchant count returned.

Existing partner endpoints (see partner-api-current-state.md):

  • POST /api/partner/v1/auth/token
  • GET /api/partner/v1/partners/{partner_id}/referral-merchants
  • GET /api/partner/v1/partners/{partner_id}/referral-merchants/summary
  • GET /api/partner/v1/merchants/{merchant_id}/transactions
  • GET /api/partner/v1/merchants/{merchant_id}/scoring-summary

All of them use HasMerchantAccess or a hardcoded WHERE referral_partner_id = $self. There is no reusable visibility resolver.

1.3 Concrete pain points (per stakeholder)

Acquisition Partner team (sales/reseller):

  • A sales partner asks for "my own merchants" → works. But asking "please group by referral code" → does not work because the scope is not modeled.
  • A partner with 2 referral codes (e.g. online code vs offline code) → must be managed via 2 manual partner.merchant_access grants.
  • Partner commission calculation is done outside the system (Excel by the Finance team). Each month Finance pulls the data, calculates manually, sends it to the partner. Error-prone.

Bank / Finance Partner team:

  • Bank Mandiri is currently not onboarded because there is no consent-based only access mechanism — we cannot give them access to one merchant that has consented without them automatically seeing other data.
  • A 3rd-party scoring bureau wants an aggregate dataset but cannot be given one because of privacy (it would see data across partners).

Fee-Share Ecosystem Partner team:

  • Fee-share with Partner A only → can be done via partner.merchant_access but it's clunky (must insert a grant per merchant).
  • Joint fee-share with Partner A + B (e.g. a settlement gateway covering 2 ecosystems) → no model. Currently worked around by creating a fake credential per ecosystem, very hard to reconcile.
  • Revenue share rates differ per source (A = 3 bps, B = 5 bps) → manual in Excel.

VC / Shareholder:

  • A holding shareholder owning 3 subsidiary partners A/B/C asks for a combined report of the 3 ecosystems each quarter. Currently the internal team manually exports, sends a PDF. Not real-time, not self-service.
  • A VC investor in Partner A (a minority shareholder of the partner, not of Kesles) asks for a view of merchant transactions in the Partner A ecosystem. No mechanism — we cannot give it without accidentally exposing other partners' data.

Regulator / Internal:

  • If OJK later requests data → it needs global access that is justified, scheduled, and auditable. Today's answer is "query the DB directly" which is not scalable for repeated questions.
  • The internal BI / data science team building a churn model → needs a cross-partner dataset. Currently access is via the DB replica — bypasses the privacy layer, not auditable.

1.4 Core gaps

GapConsequence
No concept of visibility mode per partnerEvery new need → hardcode or fake credential
No multi-partner visibility (combined)Joint ecosystem fee-share cannot be modeled, multi-investment VCs cannot be served
No structural revenue-share ruleCommissions calculated manually in Excel, error-prone, not real-time
No rich partner_type taxonomyBank/VC/Regulator forced to be tagged 'other' → cannot be policy-distinct
No audit trail of access changesIf an incident occurs ("why did Partner X suddenly see merchant Y?") → no trail
api_audit_logs does not record the effective scopePer-partner requests are recorded, but "what mode was the partner in" is not
No guardrail for dangerous modes (global)One misconfig flag can leak all data
No fallback policy for misconfigEmpty list → silently return all? silently return nothing? Ambiguous

1.5 Why we cannot just "bolt on" in handlers

We considered just adding if partner_type == 'bank' then ... in each handler. Cancelled because:

  1. Business partner types keep growing — over the next 2 years there may be insurance_agent, loan_marketplace, tax_consultant. Each type needs handler patches → an N×M combinatorial explosion.
  2. Mode combinations are not linear — Partner A could simultaneously be sales (own referral), fee-share with Partner B (network), and a customer analytics buyer (global read). Cannot be answered with a single partner_type enum.
  3. Compliance requires an explicit model — auditors / regulators will ask "what policy determines access?" The answer must be "a configuration table with an audit trail", not "line 247 of handlers.go".
  4. Self-service dashboard — the Commercial team will keep asking for "manage partner via admin UI" features that are impossible if policy is hardcoded.

So the solution must be data-driven: the visibility policy stored in a table editable via UI, with well-defined and testable semantics.

1.6 Business drivers triggering the urgency

  • Q2-2026: Kesles will sign a joint fee-share contract with 2 gateway partners (A+B). Without partner_network mode, commission reconciliation will be done manually in Excel — Finance has refused because the transaction volume is already > 2 million/month.
  • Q2-2026: Bank Mandiri onboarding for the merchant financing pilot. Without a clean explicit mode, Legal refuses to sign the partnership.
  • Q3-2026 (planned): New funding round → VCs will ask for portfolio monitoring access → without a controlled partner_network or global, shadow-IT will appear (separate internal dashboards and manual CSV exports).
  • Q3-2026 (planned): Self-service partner dashboard (partner logs in themselves, sees referral merchants, checks commission). Impossible without a consistent access model between admin-dashboard and partner-dashboard.

1.7 Viewer category + need summary

CategoryExamplesNeeds accessRequest frequencyVolume
Own referralSales / reseller / agency / affiliate partnersOnly merchants they referredDaily dashboardHundreds
Explicit consentBank, finance lender, scoring bureauOnly merchants with written consentOn-demand during underwritingTens
Partner networkVC investor in Partner A, holding shareholder A+B+C, joint ecosystem fee-shareCombined from a chosen set of partnersMonthly dashboard + weekly commission basisThousands
GlobalKesles VC shareholder, OJK regulator, internal BIAll merchantsRare, scheduledMillions

Beyond visibility, fee-sharing partners need commission rules that may differ per (beneficiary, source_ecosystem, fee_type). These rules are separate from visibility — because one beneficiary may see many sources but only earn commission from a subset.

1.8 Non-goals

What is not the goal of this document (handled elsewhere):

  • Partner authentication (OAuth client_credentials) — already OK in partner-v1-api-proposal.md.
  • Technical rate limiting — deferred to the infrastructure layer.
  • Billing engine (actual invoice generation + payout) — this document just provides the commission basis data, the billing engine is separate.
  • Partner self-service dashboard (partner logs into their own dashboard) — separate roadmap (Q3+).
  • Per-field data masking / PII redaction — already handled in the masking output layer, not touched.

2. Scope In / Out

In:

  • Access scope model (mode + partner list) on partner.partners and override on partner.api_credentials.
  • Backend resolver helper (ResolveMerchantScope).
  • Refactor existing /api/partner/v1/... endpoints to use the helper.
  • Audit trail on mode changes + per-request audit log.
  • Dashboard Partner edit UI (mode dropdown + multi-select network).
  • Revenue-share rules (billing logic separated from visibility).
  • Super_admin guardrail for global mode.

Out (deferred):

  • partner.access_scope.changed webhook (Ship F, optional).
  • Email notification to the beneficiary partner (Ship F).
  • Self-service portal where partners set their own scope (v2).
  • Time-boxed access (consent expiry) — already exists as consent_expires_at in partner.merchant_consents, not touched.

3. Terminology

TermDefinition
Viewer partnerThe partner who uses the API — has a credential, calls /api/partner/v1/.... The asker.
Source partnerThe partner who acquired a merchant — merchants.referral_partner_id. The owner of merchants in the ecosystem.
Beneficiary partnerThe partner who receives a revenue share from the source merchant's transactions. May equal the viewer.
Access scope modeEnum own_referral | explicit | partner_network | global. Determines the visible-merchant filter.
Access partner IDsAn array of UUIDs. Only relevant when mode = partner_network. The list of source partners that may be seen.

Viewer can = source can = beneficiary (a regular sales partner). They can also all differ (VC viewer, Partner A as source, Partner A also as beneficiary).


4. Access Scope Semantics

4.1 Mode table

ModeWHERE fragmentUse case
own_referral (default)m.referral_partner_id = $self_id OR EXISTS partner.merchant_accessSales / reseller — see own merchants
explicitEXISTS partner.merchant_access onlyBank/lender — only via consent
partner_networkm.referral_partner_id = ANY($access_partner_ids)VC, shareholder, joint ecosystem fee-share
globalTRUE (no filter)Regulator, Kesles VC, internal BI

Fail-closed when partner_network but the list is empty → WHERE FALSE (cannot see anything). Prevents misconfig from becoming an open door.

Implementation note: the scope-filter logic lives in services/partner_service/internal/partner/scope_resolver.go. The own_referral mode resolves m.referral_partner_id = $self_id unioned with explicit grants in partner.merchant_access, so merchants reached via a manual grant (not via referral_partner_id) are also counted in commission-basis and portfolio results.

4.2 Effective resolution (credential override)

effective_mode := credential.access_scope_mode ?? partner.access_scope_mode
effective_partner_ids := credential.access_partner_ids ?? partner.access_partner_ids

Credential-level override is useful when 1 partner has 2 credentials with different purposes — e.g. 1 read-portfolio credential (partner_network) and 1 data-export credential (global with strict audit).

4.3 Example configuration matrix

PartnerTypeModeaccess_partner_idsResult
Sequoia VC (all-Kesles)otherglobal{}Everything
East Ventures VC (invests in A)otherpartner_network{A.id}A's merchants
Holding A+B+Cotherpartner_network{A,B,C}Combined 3
Fee-share A onlyagencypartner_network{A.id}A only
Joint fee-share A+Bagencypartner_network{A,B}A+B
PT Sales Perdanasalesown_referral{}Self
Bank Mandiribankexplicit{}Consent only
OJK / regulatorotherglobal{}Everything

5. Schema Changes

5.1 Migration 046 — access scope fields

begin;
alter table partner.partners
add column if not exists access_scope_mode varchar(30) not null default 'own_referral'
check (access_scope_mode in ('own_referral', 'explicit', 'partner_network', 'global')),
add column if not exists access_partner_ids uuid[] not null default '{}'::uuid[];

alter table partner.api_credentials
add column if not exists access_scope_mode varchar(30) null
check (access_scope_mode is null
or access_scope_mode in ('own_referral', 'explicit', 'partner_network', 'global')),
add column if not exists access_partner_ids uuid[] null;

create index if not exists idx_partners_access_scope
on partner.partners (access_scope_mode)
where deleted_at is null;

create index if not exists idx_api_credentials_access_scope
on partner.api_credentials (access_scope_mode)
where access_scope_mode is not null;

-- Extend partner_type taxonomy for bank / finance / scoring bureau.
alter table merchant.partners
drop constraint if exists chk_partners_partner_type;
alter table merchant.partners
add constraint chk_partners_partner_type
check (partner_type in (
'reseller', 'agency', 'affiliate', 'community', 'sales',
'bank', 'finance_lender', 'scoring_bureau', 'venture_capital',
'shareholder', 'regulator', 'other'
));
commit;

Backward compat: default own_referral + empty array → all existing partners behave exactly as today (filter by referral_partner_id / partner.merchant_access).

5.2 Migration 047 — audit + revenue share

create table if not exists partner.access_scope_changes (
id uuid primary key default gen_random_uuid(),
partner_id uuid not null references partner.partners(id) on delete cascade,
credential_id uuid null references partner.api_credentials(id) on delete set null,
changed_by_user_id uuid null,
before_mode varchar(30) null,
after_mode varchar(30) not null,
before_partner_ids uuid[] null,
after_partner_ids uuid[] not null,
reason text not null default '',
created_at timestamptz not null default now()
);

alter table partner.api_audit_logs
add column if not exists access_scope_mode varchar(30) null,
add column if not exists merchant_scope_count int null;

create table if not exists partner.revenue_share_rules (
id uuid primary key default gen_random_uuid(),
beneficiary_partner_id uuid not null references partner.partners(id) on delete cascade,
source_partner_id uuid null references partner.partners(id) on delete cascade,
-- NULL source = a rule across every ecosystem visible to the beneficiary
fee_type varchar(30) not null,
-- 'qris_mdr' | 'service_fee' | 'subscription' | 'settlement_fee' | 'other'
basis varchar(30) not null,
-- 'gross_amount' | 'net_amount' | 'transaction_count' | 'fixed_per_merchant'
rate_bps int null,
-- basis points (10000 = 100%). Null = use fixed_amount.
fixed_amount bigint null,
effective_from date not null default current_date,
effective_to date null,
notes text not null default '',
deleted_at timestamptz null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint chk_revenue_share_one_of
check ((rate_bps is not null and fixed_amount is null)
or (rate_bps is null and fixed_amount is not null))
);

create unique index if not exists uk_revenue_share_rules_active
on partner.revenue_share_rules (
beneficiary_partner_id,
coalesce(source_partner_id, '00000000-0000-0000-0000-000000000000'::uuid),
fee_type,
effective_from
) where deleted_at is null;

6. Backend — ResolveMerchantScope helper

A single helper used by every partner endpoint. File: services/partner_service/internal/partner/scope_resolver.go (implemented as the ScopeResolver type).

type ResolvedScope struct {
Mode string // resolvedMode after override
PartnerIDs []string
PartnerID string // self
SQLFragment string
SQLArgs []any
}

func (s *Store) ResolveMerchantScope(
ctx context.Context, credentialID string,
) (ResolvedScope, error) { ... }

Refactor 5 existing endpoints (+ future) to use the helper:

EndpointExisting filterAfter helper
GET /api/partner/v1/partners/{id}/referral-merchantshardcoded referral_partner_id = $idWHERE <scope>
GET /api/partner/v1/partners/{id}/referral-merchants/summarysamesame
GET /api/partner/v1/merchants/{id}/transactionsHasMerchantAccessWHERE m.id = $id AND <scope>
GET /api/partner/v1/merchants/{id}/scoring-summarysamesame
GET /api/partner/v1/partners/{id}/portfolio (new)WHERE <scope>
GET /api/partner/v1/partners/{id}/merchant-performance (new)WHERE <scope>
GET /api/partner/v1/partners/{id}/commission-basis (new)WHERE <scope> + join revenue_share_rules

7. Dashboard UI

The Partner edit panel (master-data/partners/{id}/edit) gains an Access Control section.

Access Control
├─ Mode: [ Dropdown: own_referral | explicit | partner_network | global ]
├─ [if partner_network] Partner Network: [ multi-select searchable dropdown ]
├─ [if global] ⚠️ Requires super_admin approval + audit reason
└─ Change reason: [ textarea — required if mode changes ]

Behaviour:

  • Default load: own_referral.
  • Switch to global:
    • Only super_admin can pick (the dropdown option is disabled for other roles).
    • On save → confirmation modal "This partner will see ALL merchants. Continue?" + reason required.
    • Emit a partner.access_scope_changes row + log to internal Slack/Telegram.
  • Switch to partner_network:
    • Multi-select with at least 1 (the backend still fail-closes on empty).
    • The self-ID is auto-hidden from options (no self-reference allowed).
    • Warning banner if the list >10 partners.
  • access_partner_ids cannot include self (validator on the backend + UI).

The credential-editor sub-panel (.../partners/{id}/credentials/{cred_id}/edit) gets a Credential Override section:

Credential Override (optional)
├─ [ ] Override partner-level access scope
├─ Mode: [ Dropdown ]
└─ Partner Network: [ multi-select ]

Checkbox off → server saves NULL → inherit.


8. API Payload Examples

8.1 Admin get partner detail (dashboard)

{
"id": "…",
"partner_code": "PRN-0003",
"partner_type": "agency",
"legal_name": "PT Sales Perdana",
"access_scope": {
"mode": "partner_network",
"partner_ids": ["a-uuid", "b-uuid"],
"partner_summary": [
{ "id": "a-uuid", "code": "PRN-0001", "name": "PT Aisino Indonesia" },
{ "id": "b-uuid", "code": "PRN-0002", "name": "PT Sales Mandiri" }
]
}
}

8.2 Admin update partner scope

PATCH /api/dashboard/master-data/partners/{id}/access-scope

{
"mode": "partner_network",
"partner_ids": ["a-uuid", "b-uuid"],
"reason": "Joint ecosystem A+B fee-share per the 2026-Q2 contract"
}

8.3 Resolved-scope meta in partner endpoint responses

Every partner endpoint response carries meta.scope:

{
"items": [ ... ],
"meta": {
"scope": {
"mode": "partner_network",
"partner_ids": ["a-uuid", "b-uuid"]
},
"window": { ... }
}
}

The point is partners can self-audit "what I requested, what I got".


9. Audit & Guardrails

  1. Every mode/list change produces a partner.access_scope_changes row.
  2. Every request records access_scope_mode + merchant_scope_count in partner.api_audit_logs.
  3. global mode is gated by the super_admin role + a confirmation modal with mandatory reason + an internal alert.
  4. Self-reference is validated (a partner cannot add itself to access_partner_ids).
  5. Fail-closed when partner_network has an empty list.
  6. Out-of-scope merchant lookup (e.g. a partner hits /merchants/{id} not in scope) → 403 + audit row merchant_scope_violation=true.
  7. Rate-limit stricter for global credentials (default 10 req/minute vs 120 req/minute for scoped).

10. Rollout Phases

Ship A — Foundation (1 day) — ✅ DONE

  • Migration 046 (schema: access scope fields + partner_type taxonomy) — applied
  • Helper updatePartnerAccessScope (dashboard-api side) — core-api-side ResolveMerchantScope lives inside GetCommissionBasis as inlined logic; separate extraction deferred
  • Refactor endpoints: Partner struct + scanner + SELECT/UPDATE now carry the scope fields
  • (Deferred) Helper unit test — to be added together with core-api ResolveMerchantScope extraction
  • Dev-smoke: existing partners can still access as before (default own_referral)

Ship B — Dashboard UI (0.5 day) — ✅ DONE

  • Backend endpoint PATCH /api/dashboard/master-data/partners/{id}/access-scope
  • Dashboard: Access Control dialog from the shield row action
  • Multi-select Partner Network (self exclusion + search)
  • Super_admin guardrail for global
  • Confirmation modal + mandatory reason field

Ship C — Audit & guardrail (0.5 day) — ✅ DONE

  • Migration 047 (access_scope_changes table, new api_audit_logs columns) — applied
  • Audit log write in handler (best-effort, non-blocking)
  • (Deferred) Different rate-limit tier for global mode — defer to the infra layer (reverse proxy / middleware); no rate-limit middleware installed yet
  • Internal alert when mode becomes global (structured log partner_access_scope.global_enabled)

Ship D — Revenue share (1–2 days) — ✅ DONE

  • Migration 048 revenue_share_rules
  • CRUD endpoint /api/dashboard/master-data/partners/{id}/revenue-share-rules
  • Dashboard "Revenue Share" panel on the partner detail (green percent icon)
  • Endpoint GET /api/partner/v1/partners/{id}/commission-basis (uses rule + scope)
  • Backward compat: a partner without rules → endpoint returns rows=[] total=0

Ship E — New partner endpoints (2–3 days) — ✅ DONE

  • GET /api/partner/v1/partners/{id}/portfolio
  • GET /api/partner/v1/partners/{id}/merchant-performance (pagination + filters)
  • GET /api/partner/v1/partners/{id}/activation-funnel
  • GET /api/partner/v1/partners/{id}/churn-risk
  • GET /api/partner/v1/partners/{id}/top-performers
  • Daily materialized view v_partner_merchant_performance_30d (migration 050) — cron refresh at 02:00 WIB still needs to be set up

Ship F — Notifications (optional, 0.5 day) — ✅ DONE

  • Migration 051: webhook_url + webhook_secret on partner.partners + audit table partner.webhook_deliveries
  • Backend: after a successful PATCH access-scope → POST JSON to webhook_url with HMAC-SHA256 signature header (X-Kesles-Signature: sha256=<hex>), best-effort fire-and-forget goroutine + partner.webhook_deliveries audit row
  • Dashboard UI: dedicated PartnerWebhookDialog (purple webhook icon in the row action), set URL + rotate secret + "Test Fire" button with diagnostic response
  • Admin endpoints: PUT /api/dashboard/master-data/partners/{id}/webhook + POST /.../webhook/test
  • (Deferred) Beneficiary email notification — needs an email client in dashboard-api (not present); moved to its own ship backlog

Ship G — Purchase Order "Send" integration (optional, 1–2 days) — ⏸ PENDING

Context: today the Send button on a Purchase Order only flips status='sent' + stamps sent_at. It does not actually send to the vendor — the operator prints/emails manually. To automate:

  • PDF generator for the Purchase Order (HTML template → PDF, Download button on the detail dialog)
  • Email hook to vendors.email when /send is called (use the existing emailSender in core-api, attach the PDF)
  • WhatsApp hook to vendors.phone (optional, use the existing whatsAppClient)
  • Audit table purchase_order_send_events (channel, recipient, status, error) for compliance
  • Retry queue if the email/WhatsApp provider errors

Not in scope of this plan but recorded so it isn't lost — related doc: runbooks/partner-access-setup.md (no PO send section because PO is outside the partner domain).


11. Per-ship Rollout Checklist

Ship A ✅ DONE

  • PR: migration 044 reviewed + merged (migration 046 in current numbering)
  • PR: ResolveMerchantScope helper + refactor existing endpoints
  • Staging smoke test + production migration run
  • Production binary deploy
  • Regression check: 3 active existing partners can still access data
  • (Deferred) Unit tests ≥ 90% coverage for ResolveMerchantScope — tracked as tech-debt, not blocker

Ship B ✅ DONE

  • Backend PATCH endpoint + RBAC check (non-super_admin may set own_referral / explicit / partner_network, reject global)
  • Frontend edit panel + validation (self-ref, empty partner_network)
  • Staging verification + production deploy
  • (Deferred) Formal E2E test suite

Ship C ✅ DONE

  • Migration 047 (access_scope_changes + audit_logs columns) reviewed + applied
  • Audit handler patch merged
  • Staging: change mode → row appears in access_scope_changes
  • (Deferred) Rate-limit tier integration test + Slack webhook alert

Ship D ✅ DONE

  • Migration 048 revenue_share_rules reviewed + applied
  • Dashboard CRUD merged
  • /commission-basis handler
  • Staging: create a rule → verify commission-basis returns numbers consistent with rate_bps
  • Regression: existing partner without rules → commission endpoint returns 0 + "no rule configured" message

Ship E ✅ DONE

  • 5 new partner endpoints
  • Materialized view migration 050 + manual refresh script
  • Staging validation with a demo partner
  • Production rollout
  • (Deferred) Daily 02:00 WIB cron refresh (currently manual REFRESH)
  • (Deferred) Load test 10k merchants < 500ms p95

Ship F ✅ DONE

  • Webhook delivery (best-effort fire-and-forget goroutine, HMAC-SHA256 signed)
  • webhook_deliveries audit table
  • PartnerWebhookDialog UI (set URL + rotate secret + test fire)
  • (Deferred) Retry logic with exponential backoff (currently best-effort; the audit row is stored for manual replay)
  • (Deferred) Bilingual email notification template (needs an SMTP pipeline in dashboard-api — moved to Ship-14)
  • (Deferred) Per-partner notification_channels_enabled opt-in flag

12. Open Questions

  1. Default partner type — is own_referral mode appropriate for every existing partner type?
    • Suggestion: yes. Bank/lender are set to explicit manually at onboarding. Sales/reseller inherit the default.
  2. access_partner_ids limit — what is the cap on partners in one list?
    • Suggestion: 20. Above that, super_admin must confirm (edge case for VC / large holdings).
  3. Soft cascade — if a source partner is soft-deleted, is it auto-removed from another partner's access_partner_ids?
    • Suggestion: do not auto-remove. The query still skips merchants whose referral_partner_id is deleted. Admin cleans up manually.
  4. Revenue-share cascade — when a contract rate_bps changes, do we backfill historically or only prospectively?
    • Suggestion: prospective. effective_from locks transactions starting from that date. The historical rule remains active for old calculations.
  5. Multi-currency revenue share — USD vs IDR?
    • Suggestion: Phase 1 IDR only. Phase 2 add currency to revenue_share_rules.
  6. Audit retention — how long is partner.access_scope_changes retained?
    • Suggestion: minimum 5 years (BI / OJK compliance). Never delete by default.
  7. Bulk import access_partner_ids — does the UI support CSV upload for long lists?
    • Suggestion: defer to v2. Phase 1 manual multi-select.
  8. explicit + partner_network hybrid — does a partner need BOTH consent-based AND network view?
    • Answer: no. Modes are kept separate by design. If needed, create 2 credentials with their own modes.

13. Risk Matrix

RiskLikelihoodImpactMitigation
Misconfigured partner_network opens access too widelyMediumHighFail-closed when the list is empty; UI warning when >10 partners; audit every change
global mode mistakenly granted to a non-regulator partnerLowCriticalSuper_admin gate + confirmation modal + Slack alert + mandatory reason
Slow portfolio endpoint for a large partner networkMediumMediumDaily materialized view + idx_merchants_referral_partner_id index (already exists)
Revenue-share rule overlap (2 rules match 1 transaction)MediumMediumUnique index per (beneficiary, source, fee_type, effective_from); pick most-specific on join
Existing partners break because of the new schemaLowHighDefault own_referral + backward-compat flag; regression test on staging
Audit log grows fast (~1M/day)MediumLowPartition api_audit_logs by month; archive > 1 year to cold storage
Self-reference in access_partner_idsLowLowBackend validator + UI filter self
Credential override leak (a dev sets a test override into prod)LowHigheffective_mode shown in the credential UI; alert if the override differs from the partner default

14. Success Metrics

  • Time-to-onboard a new partner type (bank, VC) drops from a manual code change → into the UI (target < 15 minutes).
  • Audit completeness — 100% of partner requests demonstrably have access_scope_mode in the audit log.
  • Incident count — 0 incidents of "partner saw a merchant they shouldn't" within 6 months post-Ship-C.
  • Commission accuracy — < 1% delta between /commission-basis output vs manual Excel calc for the 3 large partners.
  • Performance — portfolio endpoint p95 < 500ms for a partner with ≤10k merchants.

15. Decision Log

DateDecisionReason
2026-04-22Mode 4-value enum: own_referral / explicit / partner_network / globalCovers the 4 main business scenarios; extensible via data, not code
2026-04-22Credential-level override is OPTIONAL (NULL = inherit)Flexibility for 1 partner with 2 credentials without config duplication
2026-04-22Fail-closed when partner_network is emptyPrevent a misconfig becoming an incident
2026-04-22Revenue-share table SEPARATE from access scopeSeparation of concerns — visibility ≠ billing
2026-04-22Default own_referral for backward compatZero breakage for existing partners
2026-04-22global mode requires super_adminCompliance guardrail

16. Dependencies

  • Migration 031_create_partner_api_tables.sql (already exists) — partner.merchant_access, partner.api_credentials.
  • Migration 022_add_partner_referral_to_merchants.sql (already exists) — merchants.referral_partner_id + referral_code.
  • partner-v1-api-proposal.md — existing endpoints to be refactored.
  • partner-api-current-state.md — needs to be updated after Ship A is done.

17. Next Action

  • Review + approval from stakeholders (Engineering, Product, Legal/Compliance).
  • Once approved, change the document status to "Approved" + assign the Ship A owner.
  • Open Linear/Jira tasks for Ship A–F, reference this document in each task description.