Skip to content

Plan 6 — Payout Channels Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: An organization defines payout channels — each a closed settlement behaviour (external | petty_cash), a GL credit account reached through a bank or cash journal, a field_schema and eligibility rules — starts with Malaysia’s seven seeded channels, lets each member save payout details validated against a channel’s schema, encrypted at rest and masked on every read, purges a departed member’s saved details, and hands Plan 5 a frozen, sealed settlement snapshot through payout.GetChannel, payout.Snapshot and payout.OpenSnapshot.

Architecture: One platform package, internal/platform/payout, owns three tables (payout_channel, payout_registry_entry, member_payout_profile, migration 00090), the field-schema validator, masking, application-level encryption of payout details (AES-256-GCM under a keyed, rotatable keyring from PAYOUT_DETAILS_KEY), the §9.7 definition rules, member profiles, their purge when a member is deactivated (through a new deactivation-hook registry in identity/member), the snapshot, the per-country seed registry and provisioning steps 90–91. Malaysia’s seed data lives in internal/platform/countrypack/my/payout.go and registers itself from init(). internal/modules/payout is the HTTP shell: administrator routes behind payout.channel.manage, and self-service /payout/me/… routes that act only on the caller’s own member id.

Tech Stack: Go 1.26 · Gin · pgx v5 (hand-written SQL, sqlc models only) · goose · oapi-codegen · testify · testcontainers (Postgres 17) · kernel/money · crypto/aes + crypto/cipher (GCM, standard library only)

Specs:

Every task’s requirements implicitly include this section.

  • Go floor is 1.26.0. CI derives its version from go.mod; never restate a version.
  • Ownership (wave-2 contract §2). Go code only under internal/platform/payout/…, internal/modules/payout/…, and internal/platform/countrypack/my/payout.go + payout_test.go (payout data only). Three user-directed exceptions, each append-only: internal/kernel/config/config.go gains PayoutDetailsKeysFromEnv (+ a new payoutkeys_test.go), .env.example and docs/DEPLOY_NOTES.md gain the payout-key entries (Task 6, Rulings P30, P32); and internal/platform/identity/member/deactivation.go + deactivation_test.go are new files in Plan 2’s package holding the deactivation-hook registry (Task 9, Ruling P33) — no existing file of identity/member is edited. Never edit internal/platform/posting, internal/platform/accounting/…, internal/platform/countrypack/*.go or countrypack/my/my.go. Never import anything of Plan 5 (expenses, documents) or Plan 3 (workflow).
  • Migrations 00090–00109 only (this plan uses 00090). Provisioning step orders 90–99 only (this plan uses 90, 91). Permissions payout.* only. OpenAPI paths /payout/… only (served under /v1).
  • Shared files are append-only (wave-1 contract §3): constants.yaml permissions list, api/openapi.yaml, api/public_routes.txt ([no-permission] section), internal/app/app.go, internal/app/routes_test.go (composedModules), the imports in cmd/api, cmd/migrate, cmd/orgadmin, their wiring_test.go lists, internal/kernel/db/retention_test.go lists, docs/ROADMAP.md (tick only Plan 6 lines). Generated files (internal/gen/…, internal/kernel/vocab/vocab.gen.go, packages/contracts/vocab.ts) are regenerated with make generate, never hand-merged.
  • The contract API is exact (wave-2 contract §1). payout.Channel has exactly the nine fields listed there, in that order, with those types. GetChannel(ctx, tx, id) (Channel, error) and Snapshot(ctx, tx, channelID, memberID, claimType, amount, currency) (json.RawMessage, error) keep those signatures; OpenSnapshot(raw json.RawMessage) (SettlementSnapshot, error) and ResealSnapshot(raw json.RawMessage) (json.RawMessage, bool, error) join them (the contract’s §1 is revised on this branch to match, Ruling P31). Refusals are typed errors defined in internal/platform/payout/errors.go.
  • Tenant isolation, both layers. Every new table has org_id UUID NOT NULL, ENABLE and FORCE ROW LEVEL SECURITY, and a policy named org_isolation with USING and WITH CHECK. Every tenant query in Go carries an explicit org_id = $n for every org-scoped table it names, and every INSERT names org_id (TestEveryTenantQueryCarriesAnExplicitOrgPredicate). Every FK between org-scoped tables is composite (org_id, x) REFERENCES t (org_id, id).
  • Named constraints (wave-2 §3). Every CHECK, UNIQUE, EXCLUDE, FOREIGN KEY and PRIMARY KEY in 00090 has an explicit CONSTRAINT <name>; no name may end in a Postgres-generated suffix (_check, _key, _excl, _fkey, _pkey, optionally followed by digits). Go error mapping is keyed on those names. TestPayoutConstraintsAreNamed enforces it.
  • No database functions or triggers in this plan. (If one is ever added it must SET search_path = pg_catalog, public, pg_temp and schema-qualify its tables — TestEveryFunctionPinsItsSearchPath.) No SET CONSTRAINTS in non-test Go.
  • Every new table appears in exactly one list in internal/kernel/db/retention_test.go. payout_channel and payout_registry_entry are amendable (DELETE revoked); member_payout_profile is operational — DELETE held because the application deletes from it (member delete, P14; purge on deactivation, P33). No other payout table holds DELETE.
  • Every mutation writes audit.Write on the same pgx.Tx: channel create/update, registry-entry create/update, profile save/delete/reseal/purge. An audit row never contains a raw value of a masked field (Ruling P13), and a reseal’s row carries key ids only.
  • Payout details are encrypted at rest (Rulings P15, P30–P32). A profile’s details are stored only as AES-256-GCM ciphertext (details_kid, details_nonce, details_ct); a snapshot’s masked values only as Sealed. Nothing falls back to plaintext: with no keyring installed every seal and open fails with ErrNoKeyring. Decryption happens only inside internal/platform/payout, and a raw masked value leaves the package only through OpenSnapshot. No key, and no ciphertext’s plaintext, is ever logged or put in an error message.
  • Lock order (wave-2 §3): row locks before the transaction’s first audit.Write, always parent before child — payout_channel before member_payout_profile; registry entries in id order; a member’s profiles (purge) and an org’s profiles (reseal) in id order. This plan adds no advisory-lock class. GetChannel and Snapshot take no locks and write nothing, so a caller may use them anywhere in its transaction, including after its own audit.Write.
  • No nested db.InTx. Platform functions take (ctx, tx pgx.Tx, …); only handlers and provisioning callers open transactions.
  • Money is money.Amount (NUMERIC(19,4)), a string on the wire (Money schema). Never float64.
  • Personal data (§11A, §17.3; Malaysia PDPA 2010). Member payout details are personal data: collect only what a channel’s field_schema declares, refuse unknown keys, encrypt them at rest (security principle), mask sensitivity: masked fields on every API read (the profile API never returns them unmasked to anyone), audit every change, and purge a departed member’s saved details (retention principle, Ruling P33) while claim snapshots stay for the statutory seven years. Records stay in Malaysia (deployment fact, §11A.1) — nothing here calls an external service.
  • Record-bearing names (channel and registry-entry names, field labels) carry en and ms; zh is a translation only (§11A.3).
  • Errors are RFC 7807. Handlers c.Error(apperr.New(...)) and return; they never write an error body.
  • Presence before absence (AGENTS.md). Every refusal test first shows the accepted case; every guard is watched failing once by sabotage and the red message is quoted in the task report.
  • Tests: concurrency tests bound every wait with context.WithTimeout and clean up in t.Cleanup. During development run only the packages you changed (go test ./internal/platform/payout/... -count=1). make generate after any migration, OpenAPI or constants.yaml change. One full go test -p 2 -count=1 ./... in Task 8 only.
  • Git commits end with: Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Decisions taken where the spec is silent or ambiguous. Format: Ruling: <decision> — <why> — <cost if wrong>.

  • P1 Ruling: One platform package internal/platform/payout (+ payout/payouttest fixtures), one module internal/modules/payout, Malaysia data in countrypack/my/payout.go — the wave-2 contract fixes these paths, and the three tables share the field-schema validator — splitting later is a mechanical move.
  • P2 Ruling: Hand-written pgx SQL; make generate still refreshes internal/gen/sqlc/models.go — Plan 4 R2’s pattern, and the tenant-predicate guard reads Go literals — none.
  • P3 Ruling: payout_channel rows are per organization (org_id NOT NULL), not global registry rows — credit_account_id must bind to the org’s own chart, and erp_app cannot insert org_id IS NULL rows through WITH CHECK — a platform-wide change to a seeded channel must be applied org by org.
  • P4 Ruling: registry_ref lists (banks, e-wallet providers, crypto networks) live in a new per-org table payout_registry_entry (registry, key, name, …) seeded at provisioning, not in expense_category-style global rows — same reason as P3, and one table serves every payout registry — correcting a bank’s name is per-org.
  • P5 Ruling: A valid credit account is active, postable, and the default_account_id of an active journal of type bank or cash; if the account or that journal restricts currency, every allowed currency must equal it — spec 9.7 rule 1 plus 10.2 (“bank and cash accounts are reached through a journal … the reason a payout channel’s credit_account_id resolves to a bank journal”) — an org paying from an account that no journal fronts must first create a journal for it.
  • P6 Ruling: A petty_cash channel credits through a cash journal, declares no account_no or registry_ref field, and allows exactly the base currency; the seeded one binds the default cash journal’s account (MY: 1510 Cash in hand) as a placeholder, and Plan 8 credits the paying holder’s float at settlement — spec 8.4 and 9.5 say the float is resolved at settlement, while the contract’s CreditAccountID is non-nullable — Plan 5’s generic payout rule would post a petty-cash settlement to cash-in-hand unless Plan 8 intercepts it. Decided by the user (binding on later plans): the placeholder stays; Plan 8 must post a petty-cash settlement’s credit to the paying holder’s float account, never to the channel’s CreditAccountID (cash in hand), and Plan 5’s payout posting rule must defer to Plan 8 whenever the snapshot’s settlement_behaviour is petty_cash — it never credits CreditAccountID for such a claim. Recorded in Notes for later plans.
  • P7 Ruling: A channel’s key and settlement_behaviour, and a registry entry’s registry and key, are immutable; channels and registry entries are never deleted (REVOKE DELETE), only retired — snapshots, reports and saved profiles name them by key, and spec 9.7 rule 4 forbids deleting a referenced channel — a channel created by mistake stays as a retired row.
  • P8 Ruling: Channel and registry-entry names follow Plan 4 R22 (name column in the company’s record locale + translation rows en/ms/zh); field labels and help live inline in field_schema as {en, ms, zh?} maps, en and ms required — spec 9.3 shows labels inline and requires them snapshotted with the value — a label-only edit is a schema edit (audited as such).
  • P9 Ruling: Value rules per type — text single line ≤ max (default 140); address multi-line ≤ max (default 500); phone spaces/hyphens/brackets stripped then ^\+?[0-9]{7,15}$; account_no spaces/hyphens stripped, upper-cased, ^[0-9A-Z]{4,34}$; select one of its option keys; registry_ref the key of an active entry of the named registry; file a UUID (a Plan 5 document id, whose existence Plan 6 does not check) — the spec lists the types without rules; these are the loosest rules that still normalize — a stricter bank-specific account format needs a per-registry rule later.
  • P10 Ruling: An account_no field must declare sensitivity: masked — an account number is the case masking exists for, and a channel author forgetting it would leak it to every approver — none.
  • P11 Ruling: Mask shows **** plus the last four characters when the value has at least eight, otherwise **** alone — revealing four of five characters is not masking — a short ID shows no tail to an approver.
  • P12 Ruling: The profile API never returns a masked field’s raw value, to the member or anyone else; PUT replaces the whole profile, so editing one field means re-entering the others — minimization (§11A): a raw masked value leaves this package only through OpenSnapshot, on the path that makes the transfer (P31) — a member who forgets their saved number must re-enter it.
  • P13 Ruling: A profile’s audit rows carry each field’s display value (masked for sensitive fields), the channel key and the list of changed keys — never a raw sensitive value — audit_log is append-only and hash-chained, so a raw account number written there could never be scrubbed under §17.3 — an auditor cannot recover a superseded account number from the audit trail; the claim’s settlement snapshot is the record of what was paid to.
  • P14 Ruling: member_payout_profile is operational (erp_app keeps DELETE); a member may delete their saved details, and a departed member’s are purged (P33) — it is a prefill convenience, not a statutory record (the claim snapshot is) — deleted details are recoverable only as masked audit rows.
  • P15 Ruling (user decision): A profile’s details are encrypted at the application level as one whole object — the normalized map[string]string marshalled to JSON and sealed with AES-256-GCM (random 96-bit nonce per seal, the key id stored beside it) into details_kid, details_nonce, details_ct; there is no plaintext details column. The additional authenticated data binds the ciphertext to its row (payout.profile.v1|org|member|channel), so a ciphertext copied to another member, channel or org fails to open (ErrSealBroken). Masking for display is unchanged; decryption happens only inside internal/platform/payout (Snapshot, SaveProfile‘s audit “before”, DeleteProfile, ListProfiles’ masked views, ResealProfiles, PurgeMemberProfiles) — Malaysia PDPA 2010’s security principle; whole-object rather than per-masked-field because it is simpler (one seal per row, no schema dependency) and safer (a field an administrator later marks masked, and the payee’s name, are never left in plaintext) — details can no longer be filtered in SQL (nothing does), and losing the key loses every saved profile (members re-enter them).
  • P16 Ruling: At most one saved profile per (member, channel) — spec 9.6 says “saved details per channel” — a member with two bank accounts keeps one per channel, or the org adds a second channel.
  • P17 Ruling: Snapshot requires an active channel, eligibility (positive amount, allowed currency, allowed claim type, ≤ max_amount), that the member belongs to the org (active or not — a departed member’s approved claim must still be payable), and a saved profile unless the schema has no fields; it re-validates the profile against the current schema and active registry entries; it returns version-1 JSON carrying each field’s display, labels, registry names, channel names in every locale, the credit account id and code, and account_tail, with an unmasked field’s value in plaintext and a masked field’s value sealed (P31) — the contract says the claim stores this JSON and never re-reads live rows, so everything a later reader needs must be inside it — Plan 5 renders display to approvers and calls OpenSnapshot only for the payer.
  • P18 Ruling: A channel with max_amount must allow exactly one currency, and the cap is in that currency — the spec gives the cap no currency and Plan 6 has no business converting — a multi-currency channel cannot carry a cap (split it per currency, which spec 9.2 already suggests).
  • P19 Ruling: allowed_claim_types are opaque keys matching ^[a-z][a-z0-9_]{0,39}$, never checked against Plan 5 — the contract: “It treats claim types only as string keys” — a typo’d key silently makes a channel ineligible for that claim type.
  • P20 Ruling: The settlement-behaviour vocabulary is the OpenAPI enum SettlementBehaviour (it crosses the wire), the DB CHECK payout_channel_behaviour_valid, and payout.SettlementBehaviours, pinned together by tests — not constants.yaml — spec 20.1.9: “Enums that appear in API payloads are declared in api/openapi.yaml” — none.
  • P21 Ruling: One permission, payout.channel.manage, gates channel and registry administration; the four /payout/me/… routes carry no permission and are listed under [no-permission] in api/public_routes.txt, acting only on the member id from the verified token — every member must be able to maintain their own payout details, and seeded non-Owner roles hold no grants — an org wanting to stop a member editing their own details cannot, short of deactivating them.
  • P22 Ruling: Country seeds register through payout.RegisterCountrySeeds("MY", …) from countrypack/my/payout.go, not by extending Plan 4’s countrypack.Pack interface — the contract gives Plan 6 “payout data only” in my/ and none of countrypack/*.go — country data sits in two registries.
  • P23 Ruling (confirmed by the user): The seven Malaysia channels credit through the default bank journal, except cash and petty_cash (default cash journal); crypto is seeded inactive; requires_settlement_proof is true for bank transfer, DuitNow, e-wallet, cheque and crypto, false for cash and petty cash; cheque_no is optional (finance knows it at settlement, the member does not) — paying staff in crypto carries regulatory risk and no seeded account fits it — an org wanting crypto activates it after binding an account.
  • P24 Ruling: TestPayoutConstraintsAreNamed covers the three payout tables only — Plan 4’s tables carry generated names today, so a repo-wide guard would be red on arrival — a repo-wide guard is a later, separate change.
  • P25 Ruling: Retiring the last active entry of a registry that an active channel references is refused (ErrRegistryInUse), with the registry’s active entries locked in id order first — spec 9.7 rule 3, kept true after publish — an admin must retire the channel (or add an entry) first.
  • P26 Ruling: The §9.7 rules run on create always, and on update when the resulting channel is active; an inactive channel is checked for shape only; readiness reports payout_channel_invalid for every active channel that fails them — a channel must be retirable even after its account was retired — none.
  • P27 Ruling: Seeded channels allow exactly the company’s base currency — the Malaysian seeds describe domestic payouts — an org paying abroad edits allowed_currencies.
  • P28 Ruling: cmd/worker does not link modules/payout — the worker neither provisions nor posts payout data, and TestEveryBinaryLinksTheSameModules binds only api, migrate and orgadmin — a worker job needing payout imports the platform package.
  • P29 Ruling: Plan 6 posts nothing to the ledger and registers no posting rule, so ledger.MapError has no caller here; Plan 5’s payout rule credits Channel.CreditAccountID — except for petty_cash, where it defers to Plan 8 (P6) — the contract assigns posting to Plan 5 — none.
  • P30 Ruling (user decision): The key is PAYOUT_DETAILS_KEY, kid:base64secret with the secret exactly 32 bytes (the JWT_KEYS shape, so the id travels with the key), plus optional PAYOUT_DETAILS_KEY_PREVIOUS in the same shape, open-only; both are read by config.PayoutDetailsKeysFromEnv, which refuses a missing active key, a malformed or wrong-length key, the .env.example placeholder, and a previous key sharing the active key’s id. It is required by cmd/api, the one binary that constructs the payout module and serves profiles; it is its own function, not part of config.Load, because cmd/worker also calls Load and does not link payout (P28). cmd/api builds payout.NewKeyring and calls payout.InstallKeyring once at boot; the keyring is process-wide because the contract fixes Snapshot’s signature (no key parameter) — a binary that never installs one gets ErrNoKeyring from every seal and open, never plaintext — cmd/migrate and cmd/orgadmin link payout but hold no key, so a future command in them that reads or purges details must install one first.
  • P31 Ruling (user decision): The settlement snapshot is returned already sealed: each masked field’s value is sealed (same keyring, AAD payout.snapshot.v1|member|channel|field) into SnapshotField.Sealed with Value empty; unmasked values, display, labels and account_tail stay plaintext. Plan 5 stores the JSON exactly as returned; an approver-facing reader json.Unmarshals it into SettlementSnapshot and renders Display with no key; the payer’s path calls payout.OpenSnapshot(raw), the only function that yields a raw masked value; payout.ResealSnapshot(raw) re-encrypts under the active key for rotation — sealing at birth means no code path can store the claim’s copy in plaintext, and Snapshot’s signature stays the contract’s — reading a masked value requires the key in the reading process (a report run in a keyless binary sees display only, which is the point).
  • P32 Ruling (user decision): Rotation is one active key plus at most one previous key: every new seal uses the active key; the previous one only opens. ResealProfiles(ctx, tx, c) rewrites the calling org’s profiles sealed under any other key (id order, one reseal audit row each carrying key ids only), and ResealSnapshot does the same for a stored snapshot. A previous key may be removed only after every profile and every claim snapshot under it is resealed; the operator command that runs both across all orgs belongs to Plan 5 (it owns the claim table), and until it exists a previous key is never removed and a second rotation waits (docs/DEPLOY_NOTES.md) — claim snapshots are kept seven years, so a dropped key would destroy statutory data — rotation is not a one-deploy operation.
  • P33 Ruling (user decision): PurgeMemberProfiles(ctx, tx, c, memberID) deletes a departed member’s saved profiles (locked in id order) and writes one purge audit row per profile — masked Before, no After; a profile whose ciphertext will not open (ErrUnknownKey/ErrSealBroken) is still deleted, its Before recording unreadable: true and no values (erasure must not depend on the key; ErrNoKeyring still fails, as a misconfigured binary). Settlement snapshots on claims are never touched. No code path deactivates a member today (only tests set organization_member.active = false), so Plan 6 adds, in new files in identity/member, a hook registry — member.OnDeactivate(name, hook) from init() and member.RunDeactivationHooks(ctx, tx, c, memberID), which refuses a member still active and runs hooks in name order — and registers payout.profiles from internal/platform/payout’s init(). The plan that builds member deactivation must call member.RunDeactivationHooks in the transaction that sets active = false (Notes for later plans) — PDPA 2010 retention principle; a registry is the least invasive seam (no existing identity file edited, identity never imports payout) — until deactivation exists the purge is reachable only from tests, and a binary that deactivates must link payout and install the keyring.
  • P34 Ruling (user decision): The Malaysian bank, e-wallet and crypto-network seed lists are marked “unverified — confirm before any paying customer” in a comment at each list’s definition in countrypack/my/payout.go and here — the same treatment as Plan 4’s SST rates — none; an org can add or retire entries itself.

File Task Responsibility
00090_payout.sql 1 payout_registry_entry, payout_channel, member_payout_profile (details as ciphertext: details_kid, details_nonce, details_ct); named constraints, RLS, composite FKs, REVOKE DELETE on the two amendable tables.
Path Task Responsibility
internal/platform/payout/doc.go 1 Package doc.
internal/platform/payout/errors.go 1 Every typed refusal of the package.
internal/platform/payout/pgerr.go 1 mapWriteError, keyed on constraint names.
internal/platform/payout/names.go 1 Names, validation, record-locale choice, translation-row writes and reads.
internal/platform/payout/fields.go 1 Field, ValidateSchema, ValidateDetails, Mask, display.
internal/platform/payout/registryread.go 1 RegistryEntry and the lookup ValidateDetails needs.
internal/platform/payout/channel.go 1 Contract Channel, Definition, GetChannel, GetDefinition.
internal/platform/payout/seal.go 1 Key, Keyring, InstallKeyring, Sealed, AES-256-GCM seal/open/reseal, the profile and snapshot AADs, SealDetails.
internal/platform/payout/snapshot.go 1 Snapshot, OpenSnapshot, ResealSnapshot and the version-1 snapshot types.
internal/platform/payout/payouttest/env.go 1 Fixtures (org, chart, bank + cash journals, a member, a test keyring, admin-inserted rows sealed as the platform seals them).
internal/platform/payout/define.go 2 CreateChannel, UpdateChannel, ListChannels, CheckDefinition (§9.7).
internal/platform/payout/registry.go 3 Registry-entry create, update (retire), list.
internal/platform/payout/profile.go 4 SaveProfile, DeleteProfile, ListProfiles, ResealProfiles, masked views and audit shape.
internal/platform/payout/purge.go 9 PurgeMemberProfiles and its init() registration as a deactivation hook.
internal/platform/identity/member/deactivation.go 9 New file in Plan 2’s package: DeactivationHook, OnDeactivate, RunDeactivationHooks (Ruling P33).
internal/platform/payout/seed.go, steps.go 5 Country seed registry, seeders, provisioning steps 90–91.
internal/platform/countrypack/my/payout.go 5 Malaysia’s registries and seven channels.
internal/modules/payout/ 6, 7 HTTP surface: permission, admin routes (6), self-service routes (7).
internal/kernel/config/payoutkeys_test.go 6 PayoutDetailsKeysFromEnv accepts and refuses.
Path Task Change
internal/kernel/db/retention_test.go 1 Append the three tables (member_payout_profile to operational, with the purge in its comment).
internal/gen/sqlc/models.go 1 Regenerated.
constants.yaml (+ generated vocab) 6 Append payout.channel.manage.
api/openapi.yaml (+ internal/gen/api) 6, 7 /payout/… operations and schemas.
api/public_routes.txt 7 Four [no-permission] entries.
internal/app/app.go, internal/app/routes_test.go, cmd/{api,migrate,orgadmin}/main.go, cmd/{migrate,orgadmin}/wiring_test.go 6 Compose the module; cmd/api installs the payout keyring.
internal/kernel/config/config.go 6 Append KeyedSecret, PayoutDetailsKeys, PayoutDetailsKeysFromEnv (Ruling P30).
.env.example, docs/DEPLOY_NOTES.md 6 PAYOUT_DETAILS_KEY (placeholder, refused at boot), PAYOUT_DETAILS_KEY_PREVIOUS; the rotation procedure (Ruling P32).
Tests enumerating provisioning step keys (onboarding, identity, orgadmin, migrate) 5 Expect steps 90–91.
docs/ROADMAP.md 8 Tick Plan 6 lines.

Task 1: Migration 00090 and the contract API — Channel, GetChannel, Snapshot, OpenSnapshot

Section titled “Task 1: Migration 00090 and the contract API — Channel, GetChannel, Snapshot, OpenSnapshot”

The wave-2 contract requires this task to deliver GetChannel and Snapshot (and, since the encryption ruling, OpenSnapshot) before any Plan 5 task consumes them. Everything Snapshot needs — the tables, field-schema types, value validation, masking, registry lookup, the keyring and sealing — lands here. Channel writing (Task 2), registry writing (Task 3) and profile writing (Task 4) do not; this task’s tests insert rows through the admin pool.

Files:

  • Create: db/migrations/00090_payout.sql
  • Create: internal/platform/payout/{doc,errors,pgerr,names,fields,registryread,seal,channel,snapshot}.go
  • Create: internal/platform/payout/payouttest/env.go
  • Modify: internal/kernel/db/retention_test.go; regenerate internal/gen/sqlc/models.go
  • Test: internal/platform/payout/{fields_test,channel_test,snapshot_test,schema_test}.go (package payout_test); internal/platform/payout/seal_internal_test.go (package payout — seal/open/reseal are unexported; it must not import payouttest, which imports payout)

Interfaces:

  • Consumes: accountingtest.New/SeedChart/SeedJournals/SeedMember (Plan 4), chart.CreateJournal, tenancy.OrgFromContext, money.Amount.

  • Produces:

    package payout
    const (BehaviourExternal = "external"; BehaviourPettyCash = "petty_cash")
    var SettlementBehaviours = []string{BehaviourExternal, BehaviourPettyCash}
    type Channel struct { // exactly the contract's
    ID uuid.UUID; Key string; SettlementBehaviour string; CreditAccountID uuid.UUID
    AllowedCurrencies []string; AllowedClaimTypes []string; MaxAmount *money.Amount
    RequiresSettlementProof bool; IsActive bool
    }
    type Definition struct { Channel; Name string; SortOrder int; Fields []Field }
    func GetChannel(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Channel, error)
    func GetDefinition(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Definition, error)
    func Snapshot(ctx context.Context, tx pgx.Tx, channelID, memberID uuid.UUID,
    claimType string, amount money.Amount, currency string) (json.RawMessage, error)
    const (FieldText = "text"; FieldSelect = "select"; FieldRegistryRef = "registry_ref"; FieldPhone = "phone"
    FieldAccountNo = "account_no"; FieldFile = "file"; FieldAddress = "address"
    SensitivityMasked = "masked"; MaxFields = 30)
    type Names map[string]string
    type Option struct { Key string; Label Names }
    type Field struct { Key, Type string; Required bool; Max int; Sensitivity string
    Label, Help Names; Options []Option; Registry string }
    func (f Field) Masked() bool
    func ValidateSchema(fields []Field) error // *InvalidSchemaError
    func ValidateDetails(ctx context.Context, tx pgx.Tx, fields []Field,
    details map[string]string) (map[string]string, error) // normalized; *InvalidDetailsError
    func Mask(v string) string
    type RegistryEntry struct { ID uuid.UUID; Registry, Key, Name string; IsActive bool; SortOrder int }
    const SnapshotVersion = 1
    type SettlementSnapshot struct { Version int; Channel SnapshotChannel; MemberID uuid.UUID; ProfileID *uuid.UUID
    ClaimType string; Amount money.Amount; Currency string; Fields []SnapshotField; AccountTail *string }
    type SnapshotChannel struct { ID uuid.UUID; Key, Name string; Names Names; SettlementBehaviour string
    CreditAccountID uuid.UUID; CreditAccountCode string; RequiresSettlementProof bool }
    type SnapshotField struct { Key, Type string; Label Names; Sensitivity, Registry string
    Value string // plaintext for an unmasked field; "" in stored JSON for a masked one
    Sealed *Sealed // non-nil exactly for a masked field in stored JSON (Ruling P31)
    Display string }
    func OpenSnapshot(raw json.RawMessage) (SettlementSnapshot, error) // fills Value, clears Sealed
    func ResealSnapshot(raw json.RawMessage) (json.RawMessage, bool, error) // rotation (Ruling P32)
    // seal.go (Rulings P15, P30–P32)
    type Key struct { ID string; Secret []byte }
    type Keyring struct { /* unexported */ }
    func NewKeyring(active Key, previous *Key) (*Keyring, error)
    func (k *Keyring) ActiveKeyID() string
    func InstallKeyring(k *Keyring) *Keyring // returns the one it replaced
    type Sealed struct { KeyID string `json:"kid"`; Nonce []byte `json:"nonce"`; Ciphertext []byte `json:"ct"` }
    func SealDetails(orgID, memberID, channelID uuid.UUID, details map[string]string) (Sealed, error)
    // unexported, used by Tasks 4 and 9:
    func currentKeyring() (*Keyring, error)
    func seal(plaintext, aad []byte) (Sealed, error)
    func open(s Sealed, aad []byte) ([]byte, error)
    func reseal(s Sealed, aad []byte) (Sealed, bool, error)
    func profileAAD(orgID, memberID, channelID uuid.UUID) []byte
    func openDetails(orgID, memberID, channelID uuid.UUID, s Sealed) (map[string]string, error)
    // errors.go — the complete refusal set, used by Tasks 2–9 and Plan 5:
    var ErrChannelNotFound, ErrChannelInactive, ErrNoProfile, ErrMemberNotFound,
    ErrDuplicateChannelKey, ErrRegistryEntryNotFound, ErrDuplicateRegistryKey, ErrRegistryInUse,
    ErrNoKeyring, ErrUnknownKey, ErrSealBroken error
    type IneligibleError struct { Rule, Detail string } // Rule: RuleAmount|RuleCurrency|RuleClaimType|RuleMaxAmount
    type FieldProblem struct { Key, Reason string }
    type InvalidDetailsError struct { Problems []FieldProblem }
    type InvalidSchemaError struct { Problems []string }
    type InvalidChannelError struct { Problems []string }
    // unexported, used by later tasks in this package:
    const kindChannel = "payout_channel"; const kindRegistryEntry = "payout_registry_entry"
    var keyPattern *regexp.Regexp // ^[a-z][a-z0-9_]{0,39}$
    func validateNames(n Names) string // "" when valid
    func recordName(n Names, locale string) string
    func writeNames(ctx context.Context, tx pgx.Tx, orgID uuid.UUID, kind string, id uuid.UUID, n Names) error
    func loadNames(ctx context.Context, tx pgx.Tx, orgID uuid.UUID, kind string, id uuid.UUID) (Names, error)
    func scanDefinition(row pgx.Row, extra ...any) (Definition, error)
    const channelColumns = "…" // aliased c.
    func lookupRegistryEntry(ctx context.Context, tx pgx.Tx, registry, key string) (RegistryEntry, error)
    func scanRegistryEntry(row pgx.Row) (RegistryEntry, error); const registryColumns = "…"
    func display(ctx context.Context, tx pgx.Tx, f Field, v string) (string, error)
    func mapWriteError(err error) error
    package payouttest
    type Env struct { *accountingtest.Env; Chart accountingtest.Chart; Journals accountingtest.Journals
    CashJournal chart.Journal; Member uuid.UUID
    Key payout.Key } // the active key New installed, id "test-1"
    func New(t *testing.T) *Env // installs a fresh keyring for the test
    func NewKey(t *testing.T, id string) payout.Key // 32 random bytes
    func UseKeyring(t *testing.T, active payout.Key, previous *payout.Key) *payout.Keyring // restored at Cleanup
    func (e *Env) Other(t *testing.T, name string) *Env
    type ChannelRow struct { Key, Name, Behaviour string; CreditAccountID uuid.UUID; Fields []payout.Field
    Currencies, ClaimTypes []string; MaxAmount string; RequiresProof, Inactive bool }
    func (e *Env) SeedChannel(t *testing.T, r ChannelRow) uuid.UUID
    func (e *Env) SeedRegistryEntry(t *testing.T, registry, key, name string) uuid.UUID
    func (e *Env) SeedProfile(t *testing.T, member, channel uuid.UUID, details map[string]string) uuid.UUID // sealed via SealDetails
    func BankFields() []payout.Field // payee_name, bank (my_banks), account_no (masked)
    func (e *Env) BankChannel(t *testing.T) uuid.UUID // registry my_banks/maybank + "bank_transfer" on e.Chart.Bank
  • Step 1: Write the migration

-- +goose Up
-- PAYOUT CHANNELS (spec 9). Three tables, all tenant-scoped.
--
-- Every constraint is named (wave-2 contract §3): Go maps refusals by name,
-- and internal/platform/payout's TestPayoutConstraintsAreNamed fails on any
-- Postgres-generated one. No name ends in _check/_key/_excl/_fkey/_pkey.
-- PAYOUT REGISTRIES (spec 9.3 registry_ref, 20.1.4). The org-maintained lists
-- a registry_ref field points at: banks, e-wallet providers, crypto networks.
-- Per org, not global (Ruling P4). Retire, never delete (P7). name is the
-- record-language name; other locales are translation rows
-- (entity_kind 'payout_registry_entry', field 'name').
CREATE TABLE payout_registry_entry (
id UUID NOT NULL DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
registry TEXT NOT NULL,
key TEXT NOT NULL,
name TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT payout_registry_entry_pk PRIMARY KEY (id),
CONSTRAINT payout_registry_entry_org_fk FOREIGN KEY (org_id) REFERENCES organization (id),
CONSTRAINT payout_registry_entry_org_id_uniq UNIQUE (org_id, id),
-- Keys are lower-case by the format check, so this unique is already
-- case-insensitive (spec 20.1.4's rule, without an expression index).
CONSTRAINT payout_registry_entry_key_uniq UNIQUE (org_id, registry, key),
CONSTRAINT payout_registry_entry_registry_format CHECK (registry ~ '^[a-z][a-z0-9_]{0,39}$'),
CONSTRAINT payout_registry_entry_key_format CHECK (key ~ '^[a-z0-9][a-z0-9_]{0,39}$'),
CONSTRAINT payout_registry_entry_name_present CHECK (btrim(name) <> '')
);
-- THE CHANNEL DEFINITION (spec 9.2). settlement_behaviour is a CLOSED set —
-- behaviour touches money. credit_account_id is what makes a channel more
-- than a label; which accounts qualify (P5) is checked in Go, where the
-- journal it is reached through can be read.
CREATE TABLE payout_channel (
id UUID NOT NULL DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
key TEXT NOT NULL,
name TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INT NOT NULL DEFAULT 0,
settlement_behaviour TEXT NOT NULL,
credit_account_id UUID NOT NULL,
field_schema JSONB NOT NULL DEFAULT '[]',
allowed_currencies TEXT[] NOT NULL,
-- Empty means every claim type (wave-2 contract §1).
allowed_claim_types TEXT[] NOT NULL DEFAULT '{}',
max_amount NUMERIC(19,4),
requires_settlement_proof BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT payout_channel_pk PRIMARY KEY (id),
CONSTRAINT payout_channel_org_fk FOREIGN KEY (org_id) REFERENCES organization (id),
CONSTRAINT payout_channel_org_id_uniq UNIQUE (org_id, id),
CONSTRAINT payout_channel_key_uniq UNIQUE (org_id, key),
CONSTRAINT payout_channel_credit_account_fk
FOREIGN KEY (org_id, credit_account_id) REFERENCES account (org_id, id),
CONSTRAINT payout_channel_key_format CHECK (key ~ '^[a-z][a-z0-9_]{0,39}$'),
CONSTRAINT payout_channel_name_present CHECK (btrim(name) <> ''),
CONSTRAINT payout_channel_behaviour_valid CHECK (settlement_behaviour IN ('external', 'petty_cash')),
CONSTRAINT payout_channel_schema_shape
CHECK (jsonb_typeof(field_schema) = 'array' AND jsonb_array_length(field_schema) <= 30),
CONSTRAINT payout_channel_currencies_valid
CHECK (cardinality(allowed_currencies) >= 1
AND array_position(allowed_currencies, NULL) IS NULL
AND array_to_string(allowed_currencies, ',') ~ '^[A-Z]{3}(,[A-Z]{3})*$'),
CONSTRAINT payout_channel_claim_types_valid
CHECK (array_position(allowed_claim_types, NULL) IS NULL
AND array_to_string(allowed_claim_types, ',') ~ '^([a-z][a-z0-9_]{0,39}(,[a-z][a-z0-9_]{0,39})*)?$'),
CONSTRAINT payout_channel_max_amount_positive CHECK (max_amount IS NULL OR max_amount > 0)
);
-- MEMBER PAYOUT PROFILES (spec 9.6). Saved details per channel, prefilling a
-- claim; the claim's own snapshot is the record (P14). The details are
-- personal data (spec 11A, 17.3; PDPA 2010) and exist here ONLY as
-- AES-256-GCM ciphertext of the whole normalized object (P15): details_kid
-- names the key that sealed it (rotation, P32), details_nonce is that seal's
-- random 96-bit nonce, details_ct the ciphertext and its 16-byte tag. The
-- key is outside the database (PAYOUT_DETAILS_KEY), so a dump alone yields
-- no account number. Masked on every API read, never written raw into
-- audit_log (P13), purged when the member is deactivated (P33).
CREATE TABLE member_payout_profile (
id UUID NOT NULL DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
member_id UUID NOT NULL,
channel_id UUID NOT NULL,
details_kid TEXT NOT NULL,
details_nonce BYTEA NOT NULL,
details_ct BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT member_payout_profile_pk PRIMARY KEY (id),
CONSTRAINT member_payout_profile_org_fk FOREIGN KEY (org_id) REFERENCES organization (id),
CONSTRAINT member_payout_profile_one_per_channel_uniq UNIQUE (org_id, member_id, channel_id),
CONSTRAINT member_payout_profile_member_fk
FOREIGN KEY (org_id, member_id) REFERENCES organization_member (org_id, id),
CONSTRAINT member_payout_profile_channel_fk
FOREIGN KEY (org_id, channel_id) REFERENCES payout_channel (org_id, id),
CONSTRAINT member_payout_profile_kid_format CHECK (details_kid ~ '^[A-Za-z0-9_-]{1,32}$'),
CONSTRAINT member_payout_profile_nonce_length CHECK (octet_length(details_nonce) = 12),
-- At least the GCM tag: an empty or truncated ciphertext is never a profile.
CONSTRAINT member_payout_profile_ct_length CHECK (octet_length(details_ct) >= 16)
);
CREATE INDEX member_payout_profile_member_idx ON member_payout_profile (org_id, member_id);
ALTER TABLE payout_registry_entry ENABLE ROW LEVEL SECURITY;
ALTER TABLE payout_registry_entry FORCE ROW LEVEL SECURITY;
ALTER TABLE payout_channel ENABLE ROW LEVEL SECURITY;
ALTER TABLE payout_channel FORCE ROW LEVEL SECURITY;
ALTER TABLE member_payout_profile ENABLE ROW LEVEL SECURITY;
ALTER TABLE member_payout_profile FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON payout_registry_entry
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
CREATE POLICY org_isolation ON payout_channel
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
CREATE POLICY org_isolation ON member_payout_profile
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
-- Retire, never delete (P7). 00001's default privileges granted DELETE at
-- CREATE TABLE; only a REVOKE removes it. member_payout_profile keeps DELETE
-- on purpose: a member may delete their own saved details (P14), and a
-- departed member's are purged (P33).
REVOKE DELETE ON payout_channel, payout_registry_entry FROM erp_app;
-- +goose Down
DROP TABLE member_payout_profile;
DROP TABLE payout_channel;
DROP TABLE payout_registry_entry;
  • Step 2: Retention lists. In internal/kernel/db/retention_test.go, append to amendable:
// Plan 6. A channel and a registry entry are named by key in every
// settlement snapshot and saved profile; retired, never deleted
// (spec 9.7 rule 4). The channel is also bound to a GL account.
"payout_channel",
"payout_registry_entry",

and to operational:

// Plan 6 (Rulings P14, P33). A member's saved payout details prefill
// a claim; the claim's own settlement snapshot is the record. A member
// may delete them, and payout.PurgeMemberProfiles deletes a departed
// member's (PDPA 2010 retention principle) — both need DELETE. The
// claims' snapshots are Plan 5's and are kept seven years.
"member_payout_profile",

Run make generate; commit the regenerated internal/gen/sqlc/models.go with this task.

  • Step 3: Write errors.go and pgerr.go
package payout
import (
"errors"
"fmt"
"strings"
)
var (
// ErrChannelNotFound: no such channel IN THIS ORGANIZATION — unknown and
// foreign ids are indistinguishable, as chart.ErrNotFound's are.
ErrChannelNotFound = errors.New("payout: no such payout channel in this organization")
// ErrChannelInactive: the channel is retired; nothing new is paid or
// saved against it.
ErrChannelInactive = errors.New("payout: the payout channel is inactive")
// ErrNoProfile: the member has saved no details for this channel.
ErrNoProfile = errors.New("payout: the member has no payout details for this channel")
// ErrMemberNotFound: the member is not in this organization (or, for a
// profile write, not active in it).
ErrMemberNotFound = errors.New("payout: no such member in this organization")
// ErrDuplicateChannelKey maps payout_channel_key_uniq.
ErrDuplicateChannelKey = errors.New("payout: a channel with that key already exists")
// ErrRegistryEntryNotFound: no such registry entry in this organization.
ErrRegistryEntryNotFound = errors.New("payout: no such registry entry in this organization")
// ErrDuplicateRegistryKey maps payout_registry_entry_key_uniq.
ErrDuplicateRegistryKey = errors.New("payout: that registry already has an entry with that key")
// ErrRegistryInUse: retiring the last active entry of a registry an
// active channel references (Ruling P25; spec 9.7 rule 3).
ErrRegistryInUse = errors.New("payout: the last active entry of a registry an active channel uses cannot be retired")
// ErrNoKeyring: this process installed no payout-details keyring
// (Ruling P30). Nothing is ever stored or returned in plaintext instead.
ErrNoKeyring = errors.New("payout: no payout-details keyring is installed (PAYOUT_DETAILS_KEY)")
// ErrUnknownKey: the value was sealed under a key id this process holds
// neither as active nor as previous — a rotation dropped a key too soon.
ErrUnknownKey = errors.New("payout: payout details sealed under a key this process does not hold")
// ErrSealBroken: authentication failed — the ciphertext or nonce was
// altered, it was moved to another row or snapshot, or the key under its
// id is not the one that sealed it. Never carries the plaintext.
ErrSealBroken = errors.New("payout: sealed payout details failed authentication")
)
// The eligibility rules a snapshot checks (spec 9.6).
const (
RuleAmount = "amount"
RuleCurrency = "currency"
RuleClaimType = "claim_type"
RuleMaxAmount = "max_amount"
)
// IneligibleError is Snapshot's refusal when the payout breaks one of the
// channel's eligibility rules. Rule is stable for callers to switch on.
type IneligibleError struct{ Rule, Detail string }
func (e *IneligibleError) Error() string { return "payout: channel not eligible: " + e.Detail }
// FieldProblem names one field of a payout-details map and what is wrong with it.
type FieldProblem struct{ Key, Reason string }
// InvalidDetailsError is ValidateDetails' refusal: every problem found, sorted by key.
type InvalidDetailsError struct{ Problems []FieldProblem }
func (e *InvalidDetailsError) Error() string {
parts := make([]string, len(e.Problems))
for i, p := range e.Problems {
parts[i] = p.Key + ": " + p.Reason
}
return "payout: invalid payout details: " + strings.Join(parts, "; ")
}
// InvalidSchemaError is ValidateSchema's refusal: every problem found, in field order.
type InvalidSchemaError struct{ Problems []string }
func (e *InvalidSchemaError) Error() string {
return "payout: invalid field_schema: " + strings.Join(e.Problems, "; ")
}
// InvalidChannelError is a channel definition that breaks a rule of spec 9.7
// or of Rulings P5–P7, P18 (Task 2).
type InvalidChannelError struct{ Problems []string }
func (e *InvalidChannelError) Error() string {
return fmt.Sprintf("payout: invalid channel: %s", strings.Join(e.Problems, "; "))
}
package payout
import (
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgconn"
)
// constraintErrors maps the named constraints a write can hit onto the
// domain errors callers expect. Nothing is pre-checked in Go that the
// constraint already enforces; this runs after Postgres has refused.
var constraintErrors = map[string]error{
"payout_channel_key_uniq": ErrDuplicateChannelKey,
"payout_registry_entry_key_uniq": ErrDuplicateRegistryKey,
}
func mapWriteError(err error) error {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
return err
}
if domain, ok := constraintErrors[pgErr.ConstraintName]; ok {
return fmt.Errorf("%w: %w", domain, err)
}
return err
}
  • Step 4: Write names.go
package payout
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// Names holds one string per locale: "en" and "ms" required (either may be
// the record language, spec 11A.3), "zh" optional (a UI translation only).
type Names map[string]string
var nameLocales = []string{"en", "ms", "zh"}
// validateNames answers "" for a valid Names, else the problem.
func validateNames(n Names) string {
for k, v := range n {
if k != "en" && k != "ms" && k != "zh" {
return fmt.Sprintf("has unsupported locale %q (en, ms, zh)", k)
}
if strings.TrimSpace(v) == "" || len([]rune(v)) > 200 {
return fmt.Sprintf("%s must be 1 to 200 characters", k)
}
}
if n["en"] == "" || n["ms"] == "" {
return "needs both en and ms"
}
return ""
}
// recordName is the name stored on the row: the company's record locale,
// falling back to en (countrypack.nameFor's rule).
func recordName(n Names, locale string) string {
if v := n[locale]; v != "" {
return v
}
return n["en"]
}
// writeNames makes the translation rows for (kind, id) exactly n: upsert
// each present locale, delete any absent one.
func writeNames(ctx context.Context, tx pgx.Tx, orgID uuid.UUID, kind string, id uuid.UUID, n Names) error {
for _, loc := range nameLocales {
v, ok := n[loc]
if !ok || strings.TrimSpace(v) == "" {
if _, err := tx.Exec(ctx, `
DELETE FROM translation
WHERE org_id = $1 AND entity_kind = $2 AND entity_id = $3 AND field = 'name' AND locale = $4`,
orgID, kind, id, loc); err != nil {
return fmt.Errorf("payout: names: %w", err)
}
continue
}
if _, err := tx.Exec(ctx, `
INSERT INTO translation (org_id, entity_kind, entity_id, field, locale, value)
VALUES ($1, $2, $3, 'name', $4, $5)
ON CONFLICT (org_id, entity_kind, entity_id, field, locale) WHERE org_id IS NOT NULL
DO UPDATE SET value = EXCLUDED.value`, orgID, kind, id, loc, v); err != nil {
return fmt.Errorf("payout: names: %w", err)
}
}
return nil
}
// loadNames reads every locale's name of (kind, id) — the snapshot freezes
// all of them (spec 9.3: a later rename must never restate a settled claim).
func loadNames(ctx context.Context, tx pgx.Tx, orgID uuid.UUID, kind string, id uuid.UUID) (Names, error) {
rows, err := tx.Query(ctx, `
SELECT locale, value FROM translation
WHERE org_id = $1 AND entity_kind = $2 AND entity_id = $3 AND field = 'name'`, orgID, kind, id)
if err != nil {
return nil, fmt.Errorf("payout: load names: %w", err)
}
defer rows.Close()
out := Names{}
for rows.Next() {
var loc, v string
if err := rows.Scan(&loc, &v); err != nil {
return nil, fmt.Errorf("payout: load names: %w", err)
}
out[loc] = v
}
return out, rows.Err()
}
  • Step 5: Write the failing field tests (fields_test.go, package payout_test)
func duitNow() payout.Field { // the spec 9.3 example, with the ms label P8 requires
return payout.Field{Key: "duitnow_id", Type: payout.FieldText, Required: true, Sensitivity: payout.SensitivityMasked,
Label: payout.Names{"en": "DuitNow ID", "ms": "ID DuitNow", "zh": "DuitNow 号码"}}
}
func TestValidateSchemaAcceptsTheSpecExample(t *testing.T) {
payee := payout.Field{Key: "payee_name", Type: payout.FieldText, Required: true, Max: 140,
Label: payout.Names{"en": "Payee name", "ms": "Nama penerima"}}
require.NoError(t, payout.ValidateSchema([]payout.Field{payee, duitNow()}))
require.NoError(t, payout.ValidateSchema(payouttest.BankFields()))
}
func TestValidateSchemaRefuses(t *testing.T) {
// PRESENCE: the baseline passes, so each case below fails for its own reason.
require.NoError(t, payout.ValidateSchema([]payout.Field{duitNow()}))
cases := map[string]func(f *payout.Field) []payout.Field{
"bad key": func(f *payout.Field) []payout.Field { f.Key = "Bad Key"; return []payout.Field{*f} },
"duplicate key": func(f *payout.Field) []payout.Field { return []payout.Field{*f, *f} },
"unknown type": func(f *payout.Field) []payout.Field { f.Type = "iban"; return []payout.Field{*f} },
"no ms label": func(f *payout.Field) []payout.Field { f.Label = payout.Names{"en": "x"}; return []payout.Field{*f} },
"account_no unmasked": func(f *payout.Field) []payout.Field { f.Type = payout.FieldAccountNo; f.Sensitivity = ""; return []payout.Field{*f} },
"bad sensitivity": func(f *payout.Field) []payout.Field { f.Sensitivity = "secret"; return []payout.Field{*f} },
"select no options": func(f *payout.Field) []payout.Field { f.Type = payout.FieldSelect; return []payout.Field{*f} },
"options on text": func(f *payout.Field) []payout.Field { f.Options = []payout.Option{{Key: "a", Label: f.Label}}; return []payout.Field{*f} },
"registry_ref no name": func(f *payout.Field) []payout.Field { f.Type = payout.FieldRegistryRef; return []payout.Field{*f} },
"registry on text": func(f *payout.Field) []payout.Field { f.Registry = "my_banks"; return []payout.Field{*f} },
"max on phone": func(f *payout.Field) []payout.Field { f.Type = payout.FieldPhone; f.Max = 20; return []payout.Field{*f} },
"max too large": func(f *payout.Field) []payout.Field { f.Max = 501; return []payout.Field{*f} },
}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
f := duitNow()
var invalid *payout.InvalidSchemaError
require.ErrorAs(t, payout.ValidateSchema(mutate(&f)), &invalid)
})
}
var invalid *payout.InvalidSchemaError
many := make([]payout.Field, payout.MaxFields+1)
for i := range many {
many[i] = duitNow()
many[i].Key = fmt.Sprintf("f%d", i)
}
require.ErrorAs(t, payout.ValidateSchema(many), &invalid)
}
func TestValidateDetailsNormalizes(t *testing.T) {
e := payouttest.New(t)
e.SeedRegistryEntry(t, "my_banks", "maybank", "Maybank")
phone := payout.Field{Key: "mobile", Type: payout.FieldPhone, Label: payout.Names{"en": "Mobile", "ms": "Telefon"}}
fields := append(payouttest.BankFields(), phone)
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
got, err := payout.ValidateDetails(ctx, tx, fields, map[string]string{
"payee_name": " Aisyah binti Ali ", "bank": "maybank",
"account_no": "5140 1234-5678", "mobile": "+60 12-345 6789",
})
require.NoError(t, err)
require.Equal(t, map[string]string{"payee_name": "Aisyah binti Ali", "bank": "maybank",
"account_no": "514012345678", "mobile": "+60123456789"}, got)
return nil
})
}
func TestValidateDetailsRefuses(t *testing.T) {
e := payouttest.New(t)
entry := e.SeedRegistryEntry(t, "my_banks", "maybank", "Maybank")
good := map[string]string{"payee_name": "Aisyah", "bank": "maybank", "account_no": "514012345678"}
check := func(details map[string]string) error {
return e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := payout.ValidateDetails(ctx, tx, payouttest.BankFields(), details)
return err
})
}
require.NoError(t, check(good)) // PRESENCE
with := func(k, v string) map[string]string {
m := maps.Clone(good)
if v == "" {
delete(m, k)
} else {
m[k] = v
}
return m
}
for name, details := range map[string]map[string]string{
"unknown key": with("nric", "900101145678"),
"required missing": with("account_no", ""),
"too long": with("payee_name", strings.Repeat("a", 141)),
"two lines": with("payee_name", "a\nb"),
"bad account": with("account_no", "12"),
"unknown bank": with("bank", "no_such_bank"),
} {
t.Run(name, func(t *testing.T) {
var invalid *payout.InvalidDetailsError
require.ErrorAs(t, check(details), &invalid)
})
}
// A retired entry no longer validates (Ruling P9).
_, err := e.H.Admin.Exec(context.Background(),
`UPDATE payout_registry_entry SET is_active = false WHERE id = $1`, entry)
require.NoError(t, err)
var invalid *payout.InvalidDetailsError
require.ErrorAs(t, check(good), &invalid)
require.Equal(t, "bank", invalid.Problems[0].Key)
}
func TestMask(t *testing.T) {
require.Equal(t, "****5678", payout.Mask("514012345678"))
require.Equal(t, "****5678", payout.Mask("12345678"))
require.Equal(t, "****", payout.Mask("1234567")) // fewer than eight: no tail (Ruling P11)
require.Equal(t, "****", payout.Mask(""))
}
  • Step 6: Run and watch fail. go test ./internal/platform/payout/... -count=1 → FAIL: package payout has no Go files / undefined names.

  • Step 7: Write registryread.go and fields.go

package payout
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/tenancy"
)
// RegistryEntry is one row of an org-maintained payout registry (Ruling P4).
type RegistryEntry struct {
ID uuid.UUID
Registry, Key, Name string
IsActive bool
SortOrder int
}
const registryColumns = `r.id, r.registry, r.key, r.name, r.is_active, r.sort_order`
func scanRegistryEntry(row pgx.Row) (RegistryEntry, error) {
var e RegistryEntry
err := row.Scan(&e.ID, &e.Registry, &e.Key, &e.Name, &e.IsActive, &e.SortOrder)
return e, err
}
// lookupRegistryEntry reads (registry, key) of the calling organization,
// active or not; the caller decides what a retired entry means.
func lookupRegistryEntry(ctx context.Context, tx pgx.Tx, registry, key string) (RegistryEntry, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return RegistryEntry{}, fmt.Errorf("payout: registry lookup: %w", tenancy.ErrNoOrg)
}
e, err := scanRegistryEntry(tx.QueryRow(ctx,
`SELECT `+registryColumns+` FROM payout_registry_entry r
WHERE r.org_id = $1 AND r.registry = $2 AND r.key = $3`, orgID, registry, key))
if errors.Is(err, pgx.ErrNoRows) {
return RegistryEntry{}, ErrRegistryEntryNotFound
}
if err != nil {
return RegistryEntry{}, fmt.Errorf("payout: registry lookup: %w", err)
}
return e, nil
}
package payout
import (
"context"
"errors"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"unicode"
"unicode/utf8"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// The field types of spec 9.3.
const (
FieldText = "text"
FieldSelect = "select"
FieldRegistryRef = "registry_ref"
FieldPhone = "phone"
FieldAccountNo = "account_no"
FieldFile = "file"
FieldAddress = "address"
// SensitivityMasked drives masking (spec 9.3): the channel decides what
// is hidden, not hardcoded knowledge of which column is an account number.
SensitivityMasked = "masked"
// MaxFields mirrors payout_channel_schema_shape.
MaxFields = 30
)
var fieldTypes = map[string]bool{
FieldText: true, FieldSelect: true, FieldRegistryRef: true, FieldPhone: true,
FieldAccountNo: true, FieldFile: true, FieldAddress: true,
}
var (
keyPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,39}$`)
optionPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_]{0,39}$`)
phonePattern = regexp.MustCompile(`^\+?[0-9]{7,15}$`)
accountPattern = regexp.MustCompile(`^[0-9A-Z]{4,34}$`)
phoneStrip = strings.NewReplacer(" ", "", "-", "", "(", "", ")", "")
accountStrip = strings.NewReplacer(" ", "", "-", "")
)
// Option is one choice of a select field.
type Option struct {
Key string `json:"key"`
Label Names `json:"label"`
}
// Field is one entry of a channel's field_schema (spec 9.3). Its JSON form is
// what payout_channel.field_schema stores and what the API exchanges.
type Field struct {
Key string `json:"key"`
Type string `json:"type"`
Required bool `json:"required,omitempty"`
Max int `json:"max,omitempty"`
Sensitivity string `json:"sensitivity,omitempty"`
Label Names `json:"label"`
Help Names `json:"help,omitempty"`
Options []Option `json:"options,omitempty"`
Registry string `json:"registry,omitempty"`
}
// Masked reports whether the field's value is hidden on every read.
func (f Field) Masked() bool { return f.Sensitivity == SensitivityMasked }
func (f Field) maxLen() int {
switch {
case f.Max > 0:
return f.Max
case f.Type == FieldAddress:
return 500
default:
return 140
}
}
// ValidateSchema checks a field_schema's shape — everything decidable
// without the database. Channel rules that need the database (credit
// account, registry contents, petty cash) are CheckDefinition's (Task 2).
func ValidateSchema(fields []Field) error {
var p []string
if len(fields) > MaxFields {
p = append(p, fmt.Sprintf("a channel has at most %d fields", MaxFields))
}
seen := map[string]bool{}
for i, f := range fields {
at := fmt.Sprintf("field %d (%q)", i+1, f.Key)
if !keyPattern.MatchString(f.Key) {
p = append(p, at+": key must be lower-case letters, digits and underscores, starting with a letter")
}
if seen[f.Key] {
p = append(p, at+": key is used twice")
}
seen[f.Key] = true
if !fieldTypes[f.Type] {
p = append(p, at+": unknown type "+strconv.Quote(f.Type))
continue
}
if problem := validateNames(f.Label); problem != "" {
p = append(p, at+": label "+problem)
}
if f.Help != nil {
if problem := validateNames(f.Help); problem != "" {
p = append(p, at+": help "+problem)
}
}
if f.Sensitivity != "" && f.Sensitivity != SensitivityMasked {
p = append(p, at+`: sensitivity must be "masked" or absent`)
}
if f.Type == FieldAccountNo && !f.Masked() {
p = append(p, at+": an account_no field must be masked")
}
if f.Max != 0 && ((f.Type != FieldText && f.Type != FieldAddress) || f.Max < 1 || f.Max > 500) {
p = append(p, at+": max applies only to text and address fields, between 1 and 500")
}
if f.Type == FieldSelect {
if len(f.Options) == 0 {
p = append(p, at+": a select field needs options")
}
opts := map[string]bool{}
for _, o := range f.Options {
if !optionPattern.MatchString(o.Key) || opts[o.Key] {
p = append(p, at+": option keys must be unique lower-case keys")
}
opts[o.Key] = true
if problem := validateNames(o.Label); problem != "" {
p = append(p, at+": option "+o.Key+" label "+problem)
}
}
} else if len(f.Options) > 0 {
p = append(p, at+": only a select field has options")
}
if f.Type == FieldRegistryRef {
if !keyPattern.MatchString(f.Registry) {
p = append(p, at+": a registry_ref field must name its registry")
}
} else if f.Registry != "" {
p = append(p, at+": only a registry_ref field names a registry")
}
}
if len(p) > 0 {
return &InvalidSchemaError{Problems: p}
}
return nil
}
// ValidateDetails is the one place payout details are checked on write
// (spec 9.4 mitigation 1). It returns the normalized values of the fields
// that have one, or *InvalidDetailsError naming every problem.
func ValidateDetails(ctx context.Context, tx pgx.Tx, fields []Field, details map[string]string) (map[string]string, error) {
known := make(map[string]bool, len(fields))
for _, f := range fields {
known[f.Key] = true
}
var problems []FieldProblem
for k := range details {
if !known[k] {
problems = append(problems, FieldProblem{Key: k, Reason: "not a field of this channel"})
}
}
out := make(map[string]string, len(fields))
for _, f := range fields {
v, reason, err := normalize(ctx, tx, f, details[f.Key])
if err != nil {
return nil, err
}
switch {
case reason != "":
problems = append(problems, FieldProblem{Key: f.Key, Reason: reason})
case v == "" && f.Required:
problems = append(problems, FieldProblem{Key: f.Key, Reason: "required"})
case v != "":
out[f.Key] = v
}
}
if len(problems) > 0 {
sort.Slice(problems, func(i, j int) bool { return problems[i].Key < problems[j].Key })
return nil, &InvalidDetailsError{Problems: problems}
}
return out, nil
}
// normalize answers the stored form of raw for f, or a reason it is refused
// (Ruling P9). An empty value is absent, never a problem here.
func normalize(ctx context.Context, tx pgx.Tx, f Field, raw string) (string, string, error) {
v := strings.TrimSpace(raw)
if v == "" {
return "", "", nil
}
switch f.Type {
case FieldText:
if strings.ContainsFunc(v, unicode.IsControl) {
return "", "must be a single line", nil
}
if utf8.RuneCountInString(v) > f.maxLen() {
return "", fmt.Sprintf("longer than %d characters", f.maxLen()), nil
}
case FieldAddress:
if strings.ContainsFunc(v, func(r rune) bool { return unicode.IsControl(r) && r != '\n' }) {
return "", "contains a control character", nil
}
if utf8.RuneCountInString(v) > f.maxLen() {
return "", fmt.Sprintf("longer than %d characters", f.maxLen()), nil
}
case FieldPhone:
v = phoneStrip.Replace(v)
if !phonePattern.MatchString(v) {
return "", "not a phone number (7 to 15 digits, optional leading +)", nil
}
case FieldAccountNo:
v = strings.ToUpper(accountStrip.Replace(v))
if !accountPattern.MatchString(v) {
return "", "not an account number (4 to 34 letters or digits)", nil
}
case FieldSelect:
for _, o := range f.Options {
if o.Key == v {
return v, "", nil
}
}
return "", "not one of the field's options", nil
case FieldRegistryRef:
e, err := lookupRegistryEntry(ctx, tx, f.Registry, v)
if errors.Is(err, ErrRegistryEntryNotFound) || (err == nil && !e.IsActive) {
return "", "not an active entry of registry " + f.Registry, nil
}
if err != nil {
return "", "", err
}
case FieldFile:
id, err := uuid.Parse(v)
if err != nil {
return "", "not a document id", nil
}
v = id.String()
}
return v, "", nil
}
// revealMin is the shortest value whose last four characters Mask shows
// (Ruling P11).
const revealMin = 8
// Mask is the approver-facing form of a masked value.
func Mask(v string) string {
r := []rune(v)
if len(r) < revealMin {
return "****"
}
return "****" + string(r[len(r)-4:])
}
// display is the read form of a stored value: masked when the field is,
// the entry's name for a registry_ref, the value itself otherwise.
func display(ctx context.Context, tx pgx.Tx, f Field, v string) (string, error) {
switch {
case f.Masked():
return Mask(v), nil
case f.Type == FieldRegistryRef:
e, err := lookupRegistryEntry(ctx, tx, f.Registry, v)
if errors.Is(err, ErrRegistryEntryNotFound) {
return v, nil
}
if err != nil {
return "", err
}
return e.Name, nil
default:
return v, nil
}
}
  • Step 8: Write the failing seal tests (seal_internal_test.go, package payout)
package payout
import (
"bytes"
"crypto/rand"
"testing"
"github.com/stretchr/testify/require"
)
func testKey(t *testing.T, id string) Key {
t.Helper()
b := make([]byte, 32)
_, err := rand.Read(b)
require.NoError(t, err)
return Key{ID: id, Secret: b}
}
// install swaps the process keyring for the test and restores it after.
// No test in this repository calls t.Parallel; one that swaps the keyring
// never may.
func install(t *testing.T, active Key, previous *Key) {
t.Helper()
k, err := NewKeyring(active, previous)
require.NoError(t, err)
old := InstallKeyring(k)
t.Cleanup(func() { InstallKeyring(old) })
}
func TestSealRoundTrip(t *testing.T) {
install(t, testKey(t, "k1"), nil)
aad := []byte("row-a")
a, err := seal([]byte("514012345678"), aad)
require.NoError(t, err)
b, err := seal([]byte("514012345678"), aad)
require.NoError(t, err)
require.Equal(t, "k1", a.KeyID)
require.Len(t, a.Nonce, 12)
require.NotEqual(t, a.Nonce, b.Nonce, "every seal draws a fresh nonce")
require.NotEqual(t, a.Ciphertext, b.Ciphertext)
require.False(t, bytes.Contains(a.Ciphertext, []byte("514012345678")))
got, err := open(a, aad)
require.NoError(t, err)
require.Equal(t, "514012345678", string(got))
}
func TestOpenRefusesATamperedSeal(t *testing.T) {
install(t, testKey(t, "k1"), nil)
aad := []byte("row-a")
s, err := seal([]byte("514012345678"), aad)
require.NoError(t, err)
_, err = open(s, aad)
require.NoError(t, err) // PRESENCE: the untouched seal opens
flip := func(b []byte) []byte { c := bytes.Clone(b); c[0] ^= 0x01; return c }
for name, bad := range map[string]Sealed{
"ciphertext": {KeyID: s.KeyID, Nonce: s.Nonce, Ciphertext: flip(s.Ciphertext)},
"nonce": {KeyID: s.KeyID, Nonce: flip(s.Nonce), Ciphertext: s.Ciphertext},
"short nonce": {KeyID: s.KeyID, Nonce: s.Nonce[:8], Ciphertext: s.Ciphertext},
"truncated": {KeyID: s.KeyID, Nonce: s.Nonce, Ciphertext: s.Ciphertext[:len(s.Ciphertext)-1]},
} {
t.Run(name, func(t *testing.T) {
_, err := open(bad, aad)
require.ErrorIs(t, err, ErrSealBroken)
})
}
// The same ciphertext presented as another row's: the AAD differs.
_, err = open(s, []byte("row-b"))
require.ErrorIs(t, err, ErrSealBroken)
}
func TestOpenRefusesTheWrongKey(t *testing.T) {
install(t, testKey(t, "k1"), nil)
s, err := seal([]byte("514012345678"), nil)
require.NoError(t, err)
// Same id, different secret: a deployment configured with the wrong key.
install(t, testKey(t, "k1"), nil)
_, err = open(s, nil)
require.ErrorIs(t, err, ErrSealBroken)
// An id this process does not hold at all.
install(t, testKey(t, "k2"), nil)
_, err = open(s, nil)
require.ErrorIs(t, err, ErrUnknownKey)
}
func TestNoKeyringRefusesEverything(t *testing.T) {
old := InstallKeyring(nil)
t.Cleanup(func() { InstallKeyring(old) })
_, err := seal([]byte("x"), nil)
require.ErrorIs(t, err, ErrNoKeyring)
_, err = open(Sealed{KeyID: "k1"}, nil)
require.ErrorIs(t, err, ErrNoKeyring)
}
// TestRotation is Ruling P32: the previous key opens, the active key seals,
// reseal moves a value across, and a value never resealed is lost with its key.
func TestRotation(t *testing.T) {
old := testKey(t, "k1")
install(t, old, nil)
s, err := seal([]byte("514012345678"), nil)
require.NoError(t, err)
next := testKey(t, "k2")
install(t, next, &old)
got, err := open(s, nil)
require.NoError(t, err, "the previous key still opens what it sealed")
require.Equal(t, "514012345678", string(got))
fresh, err := seal([]byte("x"), nil)
require.NoError(t, err)
require.Equal(t, "k2", fresh.KeyID, "new writes use the active key")
moved, changed, err := reseal(s, nil)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "k2", moved.KeyID)
same, changed, err := reseal(moved, nil)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, moved, same)
install(t, next, nil) // the previous key is dropped
got, err = open(moved, nil)
require.NoError(t, err)
require.Equal(t, "514012345678", string(got))
_, err = open(s, nil)
require.ErrorIs(t, err, ErrUnknownKey, "a value never resealed is lost with its key")
}
func TestNewKeyringRefuses(t *testing.T) {
good := testKey(t, "k1")
_, err := NewKeyring(good, nil)
require.NoError(t, err) // PRESENCE
other := testKey(t, "k0")
for name, c := range map[string]struct {
active Key
previous *Key
}{
"short key": {active: Key{ID: "k1", Secret: make([]byte, 16)}},
"id with a space": {active: Key{ID: "k 1", Secret: good.Secret}},
"empty id": {active: Key{ID: "", Secret: good.Secret}},
"previous same id": {active: good, previous: &Key{ID: "k1", Secret: other.Secret}},
"previous short": {active: good, previous: &Key{ID: "k0", Secret: make([]byte, 31)}},
} {
t.Run(name, func(t *testing.T) {
_, err := NewKeyring(c.active, c.previous)
require.Error(t, err)
})
}
}

Run: go test ./internal/platform/payout/ -run 'Seal|Open|Keyring|Rotation' -count=1 Expected: FAIL — build errors: undefined Key, NewKeyring, seal (and Step 5’s undefined payouttest).

  • Step 9: Write seal.go
package payout
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/json"
"fmt"
"regexp"
"sync/atomic"
"github.com/google/uuid"
)
// Key is one AES-256 key and the id stored beside everything sealed with it
// (Ruling P30). The id is what makes rotation possible: every ciphertext
// names the key that opens it.
type Key struct {
ID string
Secret []byte
}
// keyIDPattern mirrors member_payout_profile_kid_format.
var keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,32}$`)
// Keyring seals with its active key and opens with the active key or the
// previous one (Ruling P32). Immutable once built.
type Keyring struct {
activeID string
aeads map[string]cipher.AEAD
}
// NewKeyring builds a keyring from the active key and, during a rotation,
// the key being rotated away from. Errors name key ids, never key bytes.
func NewKeyring(active Key, previous *Key) (*Keyring, error) {
keys := []Key{active}
if previous != nil {
if previous.ID == active.ID {
return nil, fmt.Errorf("payout: keyring: the previous key has the active key's id %q", active.ID)
}
keys = append(keys, *previous)
}
k := &Keyring{activeID: active.ID, aeads: make(map[string]cipher.AEAD, len(keys))}
for _, key := range keys {
if !keyIDPattern.MatchString(key.ID) {
return nil, fmt.Errorf("payout: keyring: key id %q must be 1 to 32 letters, digits, _ or -", key.ID)
}
if len(key.Secret) != 32 {
return nil, fmt.Errorf("payout: keyring: key %q must be 32 bytes, got %d", key.ID, len(key.Secret))
}
block, err := aes.NewCipher(key.Secret)
if err != nil {
return nil, fmt.Errorf("payout: keyring: key %q: %w", key.ID, err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("payout: keyring: key %q: %w", key.ID, err)
}
k.aeads[key.ID] = gcm
}
return k, nil
}
// ActiveKeyID is the id every new seal carries.
func (k *Keyring) ActiveKeyID() string { return k.activeID }
var installed atomic.Pointer[Keyring]
// InstallKeyring makes k the keyring every seal and open in this process
// uses, and returns the one it replaced (nil if none). cmd/api installs one
// at boot (Task 6). It is process-wide because the contract fixes
// Snapshot's signature (Ruling P30); tests swap it through
// payouttest.UseKeyring and restore it at Cleanup.
func InstallKeyring(k *Keyring) *Keyring { return installed.Swap(k) }
func currentKeyring() (*Keyring, error) {
k := installed.Load()
if k == nil {
return nil, ErrNoKeyring
}
return k, nil
}
// Sealed is one AES-256-GCM ciphertext: the id of the key that made it, the
// seal's own random 96-bit nonce (fresh per seal, never reused), and the
// ciphertext with its 16-byte tag. []byte fields travel as base64 in JSON.
type Sealed struct {
KeyID string `json:"kid"`
Nonce []byte `json:"nonce"`
Ciphertext []byte `json:"ct"`
}
func seal(plaintext, aad []byte) (Sealed, error) {
k, err := currentKeyring()
if err != nil {
return Sealed{}, err
}
gcm := k.aeads[k.activeID]
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return Sealed{}, fmt.Errorf("payout: seal: nonce: %w", err)
}
return Sealed{KeyID: k.activeID, Nonce: nonce, Ciphertext: gcm.Seal(nil, nonce, plaintext, aad)}, nil
}
// open authenticates and decrypts s. Every failure after the key lookup is
// ErrSealBroken, with nothing of the ciphertext or plaintext attached.
func open(s Sealed, aad []byte) ([]byte, error) {
k, err := currentKeyring()
if err != nil {
return nil, err
}
gcm, ok := k.aeads[s.KeyID]
if !ok {
return nil, fmt.Errorf("%w (key id %q)", ErrUnknownKey, s.KeyID)
}
if len(s.Nonce) != gcm.NonceSize() {
return nil, ErrSealBroken
}
out, err := gcm.Open(nil, s.Nonce, s.Ciphertext, aad)
if err != nil {
return nil, ErrSealBroken
}
return out, nil
}
// reseal returns s sealed under the active key, and whether that changed
// anything (a value already under the active key is returned as is).
func reseal(s Sealed, aad []byte) (Sealed, bool, error) {
k, err := currentKeyring()
if err != nil {
return Sealed{}, false, err
}
if s.KeyID == k.activeID {
return s, false, nil
}
plain, err := open(s, aad)
if err != nil {
return Sealed{}, false, err
}
next, err := seal(plain, aad)
if err != nil {
return Sealed{}, false, err
}
return next, true, nil
}
// profileAAD binds a profile's ciphertext to its row (Ruling P15): copied
// onto another member's, channel's or organization's row it fails to open,
// rather than paying the wrong person.
func profileAAD(orgID, memberID, channelID uuid.UUID) []byte {
return []byte("payout.profile.v1|" + orgID.String() + "|" + memberID.String() + "|" + channelID.String())
}
// snapshotFieldAAD binds one sealed snapshot value to the snapshot's member,
// channel and field (Ruling P31).
func snapshotFieldAAD(memberID, channelID uuid.UUID, fieldKey string) []byte {
return []byte("payout.snapshot.v1|" + memberID.String() + "|" + channelID.String() + "|" + fieldKey)
}
// SealDetails seals a profile's normalized details as one object (Ruling
// P15). SaveProfile and ResealProfiles use it; it is exported so payouttest
// writes fixture rows exactly as the platform does.
func SealDetails(orgID, memberID, channelID uuid.UUID, details map[string]string) (Sealed, error) {
plain, err := json.Marshal(details)
if err != nil {
return Sealed{}, fmt.Errorf("payout: seal details: %w", err)
}
return seal(plain, profileAAD(orgID, memberID, channelID))
}
// openDetails is SealDetails' inverse, for the in-package readers only.
func openDetails(orgID, memberID, channelID uuid.UUID, s Sealed) (map[string]string, error) {
plain, err := open(s, profileAAD(orgID, memberID, channelID))
if err != nil {
return nil, err
}
out := map[string]string{}
if err := json.Unmarshal(plain, &out); err != nil {
return nil, fmt.Errorf("payout: stored details: %w", err)
}
return out, nil
}

The package’s tests do not build until payouttest exists; they are run at the end of Step 10.

  • Step 10: Write payouttest/env.go (a non-test package, like accountingtest; its SQL is scanned by the tenant-predicate guard, so every INSERT names org_id)
// Package payouttest is the fixture every payout test builds on: one
// organization with Plan 4's minimal chart, a bank and a cash journal, one
// member and an installed payout keyring; plus admin-pool inserts of
// channels, registry entries and profiles that bypass the platform's own
// validation, so a test can set up exactly the row it needs — including a
// row the platform would refuse. Profiles are still sealed exactly as the
// platform seals them (payout.SealDetails): there is no plaintext row shape.
package payouttest
import (
"context"
"crypto/rand"
"encoding/json"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/accounting/accountingtest"
"github.com/mcom/erp/internal/platform/accounting/chart"
"github.com/mcom/erp/internal/platform/payout"
)
type Env struct {
*accountingtest.Env
Chart accountingtest.Chart
Journals accountingtest.Journals // Misc, Purchase, Bank (default account Chart.Bank)
CashJournal chart.Journal // default account Chart.Cash
Member uuid.UUID
Key payout.Key // the active key New installed, id "test-1"
}
// New builds the fixture and installs a fresh keyring for the test's
// duration: every seal and open in the test uses Key.
func New(t *testing.T) *Env {
t.Helper()
key := NewKey(t, "test-1")
UseKeyring(t, key, nil)
e := build(t, accountingtest.New(t))
e.Key = key
return e
}
// NewKey is a random 32-byte key with the given id.
func NewKey(t *testing.T, id string) payout.Key {
t.Helper()
b := make([]byte, 32)
_, err := rand.Read(b)
require.NoError(t, err)
return payout.Key{ID: id, Secret: b}
}
// UseKeyring installs a keyring for the rest of the test and restores the
// one it replaced at Cleanup. A caller that builds on accountingtest rather
// than New (the module's HTTP harness, Plan 5's fixtures) calls it itself.
// The keyring is process-wide: a test that swaps it must not call
// t.Parallel (none in this repository does).
func UseKeyring(t *testing.T, active payout.Key, previous *payout.Key) *payout.Keyring {
t.Helper()
k, err := payout.NewKeyring(active, previous)
require.NoError(t, err)
old := payout.InstallKeyring(k)
t.Cleanup(func() { payout.InstallKeyring(old) })
return k
}
// Other seeds a second organization on the same database, fully built. It
// shares e's keyring (the keyring is per process, not per organization).
func (e *Env) Other(t *testing.T, name string) *Env {
t.Helper()
o := build(t, e.Env.Another(t, name))
o.Key = e.Key
return o
}
func build(t *testing.T, acc *accountingtest.Env) *Env {
ch := acc.SeedChart(t)
js := acc.SeedJournals(t, ch)
var cash chart.Journal
acc.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
cash, err = chart.CreateJournal(ctx, tx, acc.Clock, chart.JournalInput{
Code: "CSH", Name: "Cash", Type: chart.JournalCash, DefaultAccountID: &ch.Cash.ID, IsDefaultForType: true})
return err
})
return &Env{Env: acc, Chart: ch, Journals: js, CashJournal: cash, Member: acc.SeedMember(t, "Aisyah")}
}
type ChannelRow struct {
Key, Name, Behaviour string // Behaviour "" -> external
CreditAccountID uuid.UUID
Fields []payout.Field
Currencies []string // nil -> {"MYR"}
ClaimTypes []string
MaxAmount string // "" -> NULL
RequiresProof bool
Inactive bool
}
func (e *Env) SeedChannel(t *testing.T, r ChannelRow) uuid.UUID {
t.Helper()
if r.Behaviour == "" {
r.Behaviour = payout.BehaviourExternal
}
if r.Currencies == nil {
r.Currencies = []string{"MYR"}
}
if r.ClaimTypes == nil {
r.ClaimTypes = []string{}
}
if r.Fields == nil {
r.Fields = []payout.Field{}
}
schema, err := json.Marshal(r.Fields)
require.NoError(t, err)
var maxAmount any
if r.MaxAmount != "" {
maxAmount = r.MaxAmount
}
var id uuid.UUID
require.NoError(t, e.H.Admin.QueryRow(context.Background(), `
INSERT INTO payout_channel (org_id, key, name, is_active, settlement_behaviour, credit_account_id,
field_schema, allowed_currencies, allowed_claim_types, max_amount,
requires_settlement_proof)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::numeric,$11) RETURNING id`,
e.Org, r.Key, r.Name, !r.Inactive, r.Behaviour, r.CreditAccountID, schema, r.Currencies,
r.ClaimTypes, maxAmount, r.RequiresProof).Scan(&id))
return id
}
func (e *Env) SeedRegistryEntry(t *testing.T, registry, key, name string) uuid.UUID {
t.Helper()
var id uuid.UUID
require.NoError(t, e.H.Admin.QueryRow(context.Background(), `
INSERT INTO payout_registry_entry (org_id, registry, key, name) VALUES ($1,$2,$3,$4) RETURNING id`,
e.Org, registry, key, name).Scan(&id))
return id
}
// SeedProfile inserts details unvalidated but sealed under the installed
// keyring, bound to (org, member, channel) as SaveProfile binds them.
func (e *Env) SeedProfile(t *testing.T, member, channel uuid.UUID, details map[string]string) uuid.UUID {
t.Helper()
s, err := payout.SealDetails(e.Org, member, channel, details)
require.NoError(t, err)
var id uuid.UUID
require.NoError(t, e.H.Admin.QueryRow(context.Background(), `
INSERT INTO member_payout_profile (org_id, member_id, channel_id, details_kid, details_nonce, details_ct)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
e.Org, member, channel, s.KeyID, s.Nonce, s.Ciphertext).Scan(&id))
return id
}
// BankFields is the bank-transfer schema of spec 9.5.
func BankFields() []payout.Field {
return []payout.Field{
{Key: "payee_name", Type: payout.FieldText, Required: true, Max: 140,
Label: payout.Names{"en": "Payee name", "ms": "Nama penerima"}},
{Key: "bank", Type: payout.FieldRegistryRef, Required: true, Registry: "my_banks",
Label: payout.Names{"en": "Bank", "ms": "Bank"}},
{Key: "account_no", Type: payout.FieldAccountNo, Required: true, Sensitivity: payout.SensitivityMasked,
Label: payout.Names{"en": "Account number", "ms": "Nombor akaun"}},
}
}
// BankChannel seeds my_banks/maybank and an active "bank_transfer" channel
// on e.Chart.Bank (the BNK journal's default account), MYR only.
func (e *Env) BankChannel(t *testing.T) uuid.UUID {
t.Helper()
e.SeedRegistryEntry(t, "my_banks", "maybank", "Maybank")
return e.SeedChannel(t, ChannelRow{Key: "bank_transfer", Name: "Bank transfer",
CreditAccountID: e.Chart.Bank.ID, Fields: BankFields(), RequiresProof: true})
}

Run: go test ./internal/platform/payout/ -run 'Seal|Open|Keyring|Rotation|Mask|Validate' -count=1 Expected: PASS — Step 8’s seal tests and Step 5’s field tests (Step 11’s tests do not exist yet).

  • Step 11: Write the failing channel, snapshot and schema tests

channel_test.go:

func TestGetChannelReadsTheContractShape(t *testing.T) {
e := payouttest.New(t)
id := e.SeedChannel(t, payouttest.ChannelRow{Key: "wise_usd", Name: "Wise USD", CreditAccountID: e.Chart.Bank.ID,
Currencies: []string{"USD"}, ClaimTypes: []string{"expense_claim"}, MaxAmount: "5000", RequiresProof: true})
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
ch, err := payout.GetChannel(ctx, tx, id)
require.NoError(t, err)
max := money.MustParse("5000")
require.Equal(t, payout.Channel{ID: id, Key: "wise_usd", SettlementBehaviour: "external",
CreditAccountID: e.Chart.Bank.ID, AllowedCurrencies: []string{"USD"},
AllowedClaimTypes: []string{"expense_claim"}, MaxAmount: &max,
RequiresSettlementProof: true, IsActive: true}, ch)
return nil
})
}
func TestGetChannelOfAnotherOrgIsNotFound(t *testing.T) {
e := payouttest.New(t)
other := e.Other(t, "Other Org")
id := other.SeedChannel(t, payouttest.ChannelRow{Key: "cash", Name: "Cash", CreditAccountID: other.Chart.Cash.ID})
other.Tx(t, func(ctx context.Context, tx pgx.Tx) error { // PRESENCE
_, err := payout.GetChannel(ctx, tx, id)
return err
})
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := payout.GetChannel(ctx, tx, id)
return err
})
require.ErrorIs(t, err, payout.ErrChannelNotFound)
}

snapshot_test.go:

func snap(e *payouttest.Env, ch, member uuid.UUID, claimType, amount, cur string) (payout.SettlementSnapshot, json.RawMessage, error) {
var raw json.RawMessage
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
var err error
raw, err = payout.Snapshot(ctx, tx, ch, member, claimType, money.MustParse(amount), cur)
return err
})
var s payout.SettlementSnapshot
if err == nil {
if uerr := json.Unmarshal(raw, &s); uerr != nil {
return s, raw, uerr
}
}
return s, raw, err
}
func TestSnapshotFreezesChannelAndProfile(t *testing.T) {
e := payouttest.New(t)
ch := e.BankChannel(t)
_, err := e.H.Admin.Exec(context.Background(), `
INSERT INTO translation (org_id, entity_kind, entity_id, field, locale, value) VALUES
($1,'payout_channel',$2,'name','en','Bank transfer'),
($1,'payout_channel',$2,'name','ms','Pindahan bank'),
($1,'payout_channel',$2,'name','zh','银行转账')`, e.Org, ch)
require.NoError(t, err)
e.SeedProfile(t, e.Member, ch, map[string]string{"payee_name": "Aisyah", "bank": "maybank", "account_no": "514012345678"})
s, raw, err := snap(e, ch, e.Member, "expense_claim", "120.50", "MYR")
require.NoError(t, err)
require.Equal(t, payout.SnapshotVersion, s.Version)
require.Equal(t, "bank_transfer", s.Channel.Key)
require.Equal(t, "1520", s.Channel.CreditAccountCode)
require.Equal(t, e.Chart.Bank.ID, s.Channel.CreditAccountID)
require.Equal(t, payout.Names{"en": "Bank transfer", "ms": "Pindahan bank", "zh": "银行转账"}, s.Channel.Names)
require.True(t, s.Channel.RequiresSettlementProof)
require.Equal(t, "120.5000", s.Amount.String())
require.Len(t, s.Fields, 3)
require.Equal(t, payout.SnapshotField{Key: "bank", Type: "registry_ref", Registry: "my_banks",
Label: payout.Names{"en": "Bank", "ms": "Bank"}, Value: "maybank", Display: "Maybank"}, s.Fields[1])
// The stored JSON seals the masked value (Ruling P31); display is plain.
require.Empty(t, s.Fields[2].Value)
require.NotNil(t, s.Fields[2].Sealed)
require.Equal(t, e.Key.ID, s.Fields[2].Sealed.KeyID)
require.Equal(t, "****5678", s.Fields[2].Display)
require.Equal(t, "5678", *s.AccountTail)
require.Contains(t, string(raw), `"amount":"120.5000"`, "money must be a JSON string")
require.NotContains(t, string(raw), "514012345678", "no masked value in plaintext in the stored snapshot")
// Only OpenSnapshot yields it, for the payer.
opened, err := payout.OpenSnapshot(raw)
require.NoError(t, err)
require.Equal(t, "514012345678", opened.Fields[2].Value)
require.Nil(t, opened.Fields[2].Sealed)
require.Equal(t, "Aisyah", opened.Fields[0].Value)
// Renaming the bank afterwards changes a NEW snapshot, never the one taken.
_, err = e.H.Admin.Exec(context.Background(),
`UPDATE payout_registry_entry SET name = 'Malayan Banking' WHERE org_id = $1 AND key = 'maybank'`, e.Org)
require.NoError(t, err)
again, _, err := snap(e, ch, e.Member, "expense_claim", "120.50", "MYR")
require.NoError(t, err)
require.Equal(t, "Malayan Banking", again.Fields[1].Display)
require.Equal(t, "Maybank", s.Fields[1].Display)
}
func TestSnapshotRefusals(t *testing.T) {
e := payouttest.New(t)
ch := e.BankChannel(t)
capped := e.SeedChannel(t, payouttest.ChannelRow{Key: "capped", Name: "Capped", CreditAccountID: e.Chart.Bank.ID,
ClaimTypes: []string{"advance"}, MaxAmount: "100"})
retired := e.SeedChannel(t, payouttest.ChannelRow{Key: "retired", Name: "Retired", CreditAccountID: e.Chart.Bank.ID, Inactive: true})
e.SeedProfile(t, e.Member, ch, map[string]string{"payee_name": "Aisyah", "bank": "maybank", "account_no": "514012345678"})
noProfile := e.SeedMember(t, "Bala")
// PRESENCE: each channel accepts a well-formed payout first.
_, _, err := snap(e, ch, e.Member, "expense_claim", "10", "MYR")
require.NoError(t, err)
_, _, err = snap(e, capped, e.Member, "advance", "100", "MYR")
require.NoError(t, err)
rule := func(err error) string {
var inel *payout.IneligibleError
require.ErrorAs(t, err, &inel)
return inel.Rule
}
_, _, err = snap(e, ch, e.Member, "expense_claim", "0", "MYR")
require.Equal(t, payout.RuleAmount, rule(err))
_, _, err = snap(e, ch, e.Member, "expense_claim", "10", "USD")
require.Equal(t, payout.RuleCurrency, rule(err))
_, _, err = snap(e, capped, e.Member, "expense_claim", "10", "MYR")
require.Equal(t, payout.RuleClaimType, rule(err))
_, _, err = snap(e, capped, e.Member, "advance", "100.01", "MYR")
require.Equal(t, payout.RuleMaxAmount, rule(err))
_, _, err = snap(e, retired, e.Member, "expense_claim", "10", "MYR")
require.ErrorIs(t, err, payout.ErrChannelInactive)
_, _, err = snap(e, ch, noProfile, "expense_claim", "10", "MYR")
require.ErrorIs(t, err, payout.ErrNoProfile)
_, _, err = snap(e, ch, uuid.New(), "expense_claim", "10", "MYR")
require.ErrorIs(t, err, payout.ErrMemberNotFound)
_, _, err = snap(e, uuid.New(), e.Member, "expense_claim", "10", "MYR")
require.ErrorIs(t, err, payout.ErrChannelNotFound)
// The schema gains a required field after the profile was saved: the
// snapshot re-validates against the CURRENT schema (Ruling P17).
fields := append(payouttest.BankFields(), payout.Field{Key: "branch", Type: payout.FieldText, Required: true,
Label: payout.Names{"en": "Branch", "ms": "Cawangan"}})
schema, _ := json.Marshal(fields)
_, err = e.H.Admin.Exec(context.Background(), `UPDATE payout_channel SET field_schema = $1 WHERE id = $2`, schema, ch)
require.NoError(t, err)
_, _, err = snap(e, ch, e.Member, "expense_claim", "10", "MYR")
var invalid *payout.InvalidDetailsError
require.ErrorAs(t, err, &invalid)
}
// TestSnapshotRefusesATamperedOrMovedProfile: the profile's ciphertext is
// authenticated and bound to its row (Ruling P15).
func TestSnapshotRefusesATamperedOrMovedProfile(t *testing.T) {
e := payouttest.New(t)
ch := e.BankChannel(t)
prof := e.SeedProfile(t, e.Member, ch, map[string]string{"payee_name": "Aisyah", "bank": "maybank", "account_no": "514012345678"})
bala := e.SeedMember(t, "Bala")
e.SeedProfile(t, bala, ch, map[string]string{"payee_name": "Bala", "bank": "maybank", "account_no": "998877665544"})
_, _, err := snap(e, ch, e.Member, "expense_claim", "10", "MYR")
require.NoError(t, err) // PRESENCE
_, _, err = snap(e, ch, bala, "expense_claim", "10", "MYR")
require.NoError(t, err) // PRESENCE
// Aisyah's ciphertext copied onto Bala's row does not open there.
_, err = e.H.Admin.Exec(context.Background(), `
UPDATE member_payout_profile b
SET details_kid = a.details_kid, details_nonce = a.details_nonce, details_ct = a.details_ct
FROM member_payout_profile a
WHERE a.id = $1 AND b.org_id = a.org_id AND b.member_id = $2 AND b.channel_id = a.channel_id`, prof, bala)
require.NoError(t, err)
_, _, err = snap(e, ch, bala, "expense_claim", "10", "MYR")
require.ErrorIs(t, err, payout.ErrSealBroken)
// One flipped bit in Aisyah's own ciphertext.
_, err = e.H.Admin.Exec(context.Background(), `
UPDATE member_payout_profile SET details_ct = set_byte(details_ct, 0, get_byte(details_ct, 0) # 1)
WHERE id = $1`, prof)
require.NoError(t, err)
_, _, err = snap(e, ch, e.Member, "expense_claim", "10", "MYR")
require.ErrorIs(t, err, payout.ErrSealBroken)
require.NotContains(t, err.Error(), "514012345678")
}
// TestSnapshotSurvivesKeyRotation is Ruling P32 for a stored snapshot.
func TestSnapshotSurvivesKeyRotation(t *testing.T) {
e := payouttest.New(t)
ch := e.BankChannel(t)
e.SeedProfile(t, e.Member, ch, map[string]string{"payee_name": "Aisyah", "bank": "maybank", "account_no": "514012345678"})
_, raw, err := snap(e, ch, e.Member, "expense_claim", "10", "MYR")
require.NoError(t, err)
next := payouttest.NewKey(t, "test-2")
payouttest.UseKeyring(t, next, &e.Key)
opened, err := payout.OpenSnapshot(raw)
require.NoError(t, err, "the previous key opens a snapshot it sealed")
require.Equal(t, "514012345678", opened.Fields[2].Value)
fresh, _, err := snap(e, ch, e.Member, "expense_claim", "10", "MYR")
require.NoError(t, err)
require.Equal(t, "test-2", fresh.Fields[2].Sealed.KeyID, "a new snapshot seals under the active key")
moved, changed, err := payout.ResealSnapshot(raw)
require.NoError(t, err)
require.True(t, changed)
var m payout.SettlementSnapshot
require.NoError(t, json.Unmarshal(moved, &m))
require.Equal(t, "test-2", m.Fields[2].Sealed.KeyID)
require.Equal(t, "****5678", m.Fields[2].Display)
_, changed, err = payout.ResealSnapshot(moved)
require.NoError(t, err)
require.False(t, changed)
payouttest.UseKeyring(t, next, nil) // the previous key is dropped
opened, err = payout.OpenSnapshot(moved)
require.NoError(t, err)
require.Equal(t, "514012345678", opened.Fields[2].Value)
_, err = payout.OpenSnapshot(raw)
require.ErrorIs(t, err, payout.ErrUnknownKey)
// A sealed value is bound to its snapshot's member.
tampered := strings.Replace(string(moved), e.Member.String(), uuid.NewString(), 1)
_, err = payout.OpenSnapshot(json.RawMessage(tampered))
require.ErrorIs(t, err, payout.ErrSealBroken)
}
func TestAChannelWithNoFieldsNeedsNoProfile(t *testing.T) {
e := payouttest.New(t)
ch := e.SeedChannel(t, payouttest.ChannelRow{Key: "cash", Name: "Cash", CreditAccountID: e.Chart.Cash.ID})
s, _, err := snap(e, ch, e.Member, "expense_claim", "10", "MYR")
require.NoError(t, err)
require.Nil(t, s.ProfileID)
require.Empty(t, s.Fields)
require.Nil(t, s.AccountTail)
}
// TestSnapshotTakesNoLocks: the contract lets Plan 5 call Snapshot after its
// own audit.Write, which is safe only if Snapshot locks nothing.
func TestSnapshotTakesNoLocks(t *testing.T) {
e := payouttest.New(t)
ch := e.BankChannel(t)
prof := e.SeedProfile(t, e.Member, ch, map[string]string{"payee_name": "A", "bank": "maybank", "account_no": "514012345678"})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
t.Cleanup(cancel)
tx, err := e.H.App.Begin(ctx) // held open across the probe below
require.NoError(t, err)
t.Cleanup(func() { _ = tx.Rollback(context.Background()) })
_, err = tx.Exec(ctx, "SELECT set_config('app.current_org', $1, true)", e.Org.String())
require.NoError(t, err)
_, err = payout.Snapshot(tenancy.WithOrg(ctx, e.Org), tx, ch, e.Member, "x", money.MustParse("1"), "MYR")
require.NoError(t, err)
// PRESENCE of the probe's power: a FOR UPDATE in this open tx would block NOWAIT below.
for _, q := range []string{
`SELECT 1 FROM payout_channel WHERE id = $1 FOR UPDATE NOWAIT`,
`SELECT 1 FROM member_payout_profile WHERE id = $1 FOR UPDATE NOWAIT`,
} {
id := ch
if strings.Contains(q, "member_payout_profile") {
id = prof
}
_, err := e.H.Admin.Exec(ctx, q, id)
require.NoError(t, err, "Snapshot left a row lock: %s", q)
}
}

Write a separate one-off sabotage for the lock test in the task report: add FOR SHARE OF c to Snapshot’s query, watch TestSnapshotTakesNoLocks fail with could not obtain lock on row, restore.

schema_test.go:

// TestPayoutConstraintsAreNamed holds wave-2 contract §3 for this plan's
// tables (Ruling P24): every constraint named, none with a generated name.
func TestPayoutConstraintsAreNamed(t *testing.T) {
h := testdb.Start(t)
rows, err := h.Admin.Query(context.Background(), `
SELECT c.conrelid::regclass::text, c.conname
FROM pg_constraint c
WHERE c.conrelid IN ('public.payout_channel'::regclass, 'public.payout_registry_entry'::regclass,
'public.member_payout_profile'::regclass)
AND c.contype IN ('c','u','x','f','p')`)
require.NoError(t, err)
defer rows.Close()
generated := regexp.MustCompile(`_(check|key|excl|fkey|pkey)[0-9]*$`)
var n int
var bad []string
for rows.Next() {
var table, name string
require.NoError(t, rows.Scan(&table, &name))
n++
if generated.MatchString(name) {
bad = append(bad, table+"."+name)
}
}
require.NoError(t, rows.Err())
require.GreaterOrEqual(t, n, 20, "found %d constraints on the payout tables; the query is not reaching them", n)
require.Empty(t, bad, "constraints with Postgres-generated names; name them explicitly: %v", bad)
}
// TestSettlementBehavioursMatchTheCheck pins Go's closed set to the DB's (Ruling P20).
func TestSettlementBehavioursMatchTheCheck(t *testing.T) {
h := testdb.Start(t)
var def string
require.NoError(t, h.Admin.QueryRow(context.Background(), `
SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = 'payout_channel_behaviour_valid'`).Scan(&def))
found := regexp.MustCompile(`'([a-z_]+)'`).FindAllStringSubmatch(def, -1)
var got []string
for _, m := range found {
got = append(got, m[1])
}
require.ElementsMatch(t, payout.SettlementBehaviours, got, "CHECK is %s", def)
}
  • Step 12: Run and watch fail. go test ./internal/platform/payout/... -count=1 → FAIL: undefined payout.GetChannel, payout.Snapshot, payout.OpenSnapshot, payout.ResealSnapshot, payout.Channel.

  • Step 13: Write doc.go, channel.go and snapshot.go

// Package payout owns payout channels (spec 9): the org-defined channel
// definitions, their field_schema and GL binding, the registries a
// registry_ref field points at, members' saved payout details, and the
// settlement snapshot a claim freezes.
//
// Plan 5 consumes GetChannel, Snapshot, OpenSnapshot and ResealSnapshot
// (wave-2 contract §1, as revised). This package never imports claims; it
// treats claim types as opaque keys.
//
// Member payout details are personal data (spec 11A, 17.3; Malaysia PDPA
// 2010). They are stored only as AES-256-GCM ciphertext under the keyring
// cmd/api installs (seal.go), and decrypted only inside this package. A raw
// masked value leaves it only through OpenSnapshot, on the payer's path.
// Every other read — the profile views, the audit rows, a stored snapshot
// read without OpenSnapshot — carries Mask's form.
package payout
package payout
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/money"
"github.com/mcom/erp/internal/kernel/tenancy"
)
// The closed set of spec 9.2 (Ruling P20): behaviour touches money.
const (
BehaviourExternal = "external"
BehaviourPettyCash = "petty_cash"
)
var SettlementBehaviours = []string{BehaviourExternal, BehaviourPettyCash}
// Translation entity kinds for channel and registry-entry names (Ruling P8).
const (
kindChannel = "payout_channel"
kindRegistryEntry = "payout_registry_entry"
)
// Channel is one org-defined payout channel (§9.2). Its shape is fixed by
// the wave-2 contract §1; do not add fields — add them to Definition.
type Channel struct {
ID uuid.UUID
Key string
SettlementBehaviour string // "external" | "petty_cash" — closed set
CreditAccountID uuid.UUID
AllowedCurrencies []string
AllowedClaimTypes []string // claim-type keys; empty = all
MaxAmount *money.Amount
RequiresSettlementProof bool
IsActive bool
}
// Definition is a Channel with what an administrator edits besides.
type Definition struct {
Channel
Name string // record-language name; other locales are translation rows
SortOrder int
Fields []Field
}
const channelColumns = `c.id, c.key, c.name, c.settlement_behaviour, c.credit_account_id, c.allowed_currencies,
c.allowed_claim_types, c.max_amount::text, c.requires_settlement_proof, c.is_active, c.sort_order, c.field_schema`
// scanDefinition scans channelColumns, then extra destinations in order.
// max_amount is read as text and parsed: a NULL numeric has no Amount.
func scanDefinition(row pgx.Row, extra ...any) (Definition, error) {
var (
d Definition
maxAmount *string
schema []byte
)
dest := append([]any{&d.ID, &d.Key, &d.Name, &d.SettlementBehaviour, &d.CreditAccountID, &d.AllowedCurrencies,
&d.AllowedClaimTypes, &maxAmount, &d.RequiresSettlementProof, &d.IsActive, &d.SortOrder, &schema}, extra...)
if err := row.Scan(dest...); err != nil {
return Definition{}, err
}
if maxAmount != nil {
a, err := money.Parse(*maxAmount)
if err != nil {
return Definition{}, fmt.Errorf("payout: stored max_amount: %w", err)
}
d.MaxAmount = &a
}
if err := json.Unmarshal(schema, &d.Fields); err != nil {
return Definition{}, fmt.Errorf("payout: stored field_schema: %w", err)
}
if d.Fields == nil {
d.Fields = []Field{}
}
if d.AllowedClaimTypes == nil {
d.AllowedClaimTypes = []string{}
}
return d, nil
}
// GetChannel reads one channel of the calling org (wave-2 contract §1),
// active or not. An unknown id and another org's id are both ErrChannelNotFound.
// It takes no lock.
func GetChannel(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Channel, error) {
d, err := GetDefinition(ctx, tx, id)
if err != nil {
return Channel{}, err
}
return d.Channel, nil
}
// GetDefinition is GetChannel with the name, sort order and field schema.
func GetDefinition(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Definition, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return Definition{}, fmt.Errorf("payout: get channel: %w", tenancy.ErrNoOrg)
}
d, err := scanDefinition(tx.QueryRow(ctx,
`SELECT `+channelColumns+` FROM payout_channel c WHERE c.id = $1 AND c.org_id = $2`, id, orgID))
if errors.Is(err, pgx.ErrNoRows) {
return Definition{}, ErrChannelNotFound
}
if err != nil {
return Definition{}, fmt.Errorf("payout: get channel: %w", err)
}
return d, nil
}
package payout
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/money"
"github.com/mcom/erp/internal/kernel/tenancy"
)
// SnapshotVersion is the shape below. A later shape is a new version; a
// stored snapshot is never rewritten.
const SnapshotVersion = 1
// SettlementSnapshot is what a claim stores (spec 9.4): everything a later
// reader needs to know what was paid, to whom, through which channel and
// against which GL account, without reading a live row (Ruling P17).
type SettlementSnapshot struct {
Version int `json:"version"`
Channel SnapshotChannel `json:"channel"`
MemberID uuid.UUID `json:"member_id"`
ProfileID *uuid.UUID `json:"profile_id"`
ClaimType string `json:"claim_type"`
Amount money.Amount `json:"amount"`
Currency string `json:"currency"`
Fields []SnapshotField `json:"fields"`
// AccountTail is the last four characters of the first masked field
// long enough to show one — what reports and exports filter on (spec 9.4
// mitigation 3). nil when there is none.
AccountTail *string `json:"account_tail"`
}
type SnapshotChannel struct {
ID uuid.UUID `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Names Names `json:"names"`
SettlementBehaviour string `json:"settlement_behaviour"`
CreditAccountID uuid.UUID `json:"credit_account_id"`
CreditAccountCode string `json:"credit_account_code"`
RequiresSettlementProof bool `json:"requires_settlement_proof"`
}
// SnapshotField is one frozen field. In the stored JSON an unmasked field
// carries its Value in plaintext and a masked field carries it only as
// Sealed (Ruling P31); Display is always the approver's form. OpenSnapshot
// fills Value and clears Sealed. A consumer shows Display, never Value, to
// anyone but the person making the transfer (spec 9.3 masking).
type SnapshotField struct {
Key string `json:"key"`
Type string `json:"type"`
Label Names `json:"label"`
Sensitivity string `json:"sensitivity,omitempty"`
Registry string `json:"registry,omitempty"`
Value string `json:"value,omitempty"`
Sealed *Sealed `json:"sealed,omitempty"`
Display string `json:"display"`
}
// Snapshot freezes what a payout will be made against: the channel and the
// member's payout profile values, validated against the channel's
// field_schema. The claim stores the returned JSON; it is never re-read from
// live rows. (Wave-2 contract §1.)
//
// The JSON is returned already sealed (Ruling P31): every masked field's
// value is AES-256-GCM ciphertext under the active payout key, bound to this
// member, channel and field; display, labels and account_tail are plaintext.
// Store it as returned. Read masked values only through OpenSnapshot.
//
// It takes no lock and writes nothing, so a caller may use it at any point
// in its transaction (Global Constraints: lock order).
func Snapshot(ctx context.Context, tx pgx.Tx, channelID, memberID uuid.UUID,
claimType string, amount money.Amount, currency string) (json.RawMessage, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return nil, fmt.Errorf("payout: snapshot: %w", tenancy.ErrNoOrg)
}
var (
accountCode string
profileID *uuid.UUID
kid *string
nonce, ct []byte
)
d, err := scanDefinition(tx.QueryRow(ctx, `
SELECT `+channelColumns+`, a.code, p.id, p.details_kid, p.details_nonce, p.details_ct
FROM payout_channel c
JOIN account a ON a.org_id = c.org_id AND a.id = c.credit_account_id
LEFT JOIN member_payout_profile p
ON p.org_id = c.org_id AND p.channel_id = c.id AND p.member_id = $3
WHERE c.id = $1 AND c.org_id = $2`, channelID, orgID, memberID),
&accountCode, &profileID, &kid, &nonce, &ct)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrChannelNotFound
}
if err != nil {
return nil, fmt.Errorf("payout: snapshot: %w", err)
}
if !d.IsActive {
return nil, ErrChannelInactive
}
if err := eligible(d.Channel, claimType, amount, currency); err != nil {
return nil, err
}
var isMember bool
if err := tx.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM organization_member m WHERE m.id = $1 AND m.org_id = $2)`,
memberID, orgID).Scan(&isMember); err != nil {
return nil, fmt.Errorf("payout: snapshot: member: %w", err)
}
if !isMember {
return nil, ErrMemberNotFound
}
details := map[string]string{}
if profileID == nil {
if len(d.Fields) > 0 {
return nil, ErrNoProfile
}
} else if details, err = openDetails(orgID, memberID, d.ID, Sealed{KeyID: *kid, Nonce: nonce, Ciphertext: ct}); err != nil {
return nil, fmt.Errorf("payout: snapshot: %w", err)
}
values, err := ValidateDetails(ctx, tx, d.Fields, details)
if err != nil {
return nil, err
}
names, err := loadNames(ctx, tx, orgID, kindChannel, d.ID)
if err != nil {
return nil, err
}
s := SettlementSnapshot{
Version: SnapshotVersion,
Channel: SnapshotChannel{ID: d.ID, Key: d.Key, Name: d.Name, Names: names,
SettlementBehaviour: d.SettlementBehaviour, CreditAccountID: d.CreditAccountID,
CreditAccountCode: accountCode, RequiresSettlementProof: d.RequiresSettlementProof},
MemberID: memberID, ProfileID: profileID, ClaimType: claimType,
Amount: amount, Currency: currency, Fields: []SnapshotField{},
}
for _, f := range d.Fields {
v, ok := values[f.Key]
if !ok {
continue
}
disp, err := display(ctx, tx, f, v)
if err != nil {
return nil, err
}
if f.Masked() && s.AccountTail == nil && len([]rune(v)) >= revealMin {
r := []rune(v)
tail := string(r[len(r)-4:])
s.AccountTail = &tail
}
sf := SnapshotField{Key: f.Key, Type: f.Type, Label: f.Label,
Sensitivity: f.Sensitivity, Registry: f.Registry, Display: disp}
if f.Masked() {
sealed, err := seal([]byte(v), snapshotFieldAAD(memberID, d.ID, f.Key))
if err != nil {
return nil, fmt.Errorf("payout: snapshot: %w", err)
}
sf.Sealed = &sealed
} else {
sf.Value = v
}
s.Fields = append(s.Fields, sf)
}
return json.Marshal(s)
}
// OpenSnapshot is the one way to read a masked value out of a stored
// snapshot (Ruling P31): it parses raw, opens every sealed field into Value
// and clears Sealed. Call it only on the path that makes the transfer; any
// other reader json.Unmarshals raw into SettlementSnapshot and renders
// Display, which needs no key. Errors: ErrNoKeyring, ErrUnknownKey,
// ErrSealBroken (tampered, or moved to another member or channel).
func OpenSnapshot(raw json.RawMessage) (SettlementSnapshot, error) {
s, err := parseSnapshot(raw)
if err != nil {
return SettlementSnapshot{}, err
}
for i := range s.Fields {
f := &s.Fields[i]
if f.Sealed == nil {
continue
}
v, err := open(*f.Sealed, snapshotFieldAAD(s.MemberID, s.Channel.ID, f.Key))
if err != nil {
return SettlementSnapshot{}, fmt.Errorf("payout: open snapshot field %s: %w", f.Key, err)
}
f.Value, f.Sealed = string(v), nil
}
return s, nil
}
// ResealSnapshot re-encrypts every sealed value of a stored snapshot under
// the active key (Ruling P32) and reports whether it changed anything; if
// not, raw is returned untouched. The plaintext is unchanged, so a reseal
// changes the claim's storage encoding, not its record.
func ResealSnapshot(raw json.RawMessage) (json.RawMessage, bool, error) {
s, err := parseSnapshot(raw)
if err != nil {
return nil, false, err
}
changed := false
for i := range s.Fields {
f := &s.Fields[i]
if f.Sealed == nil {
continue
}
next, moved, err := reseal(*f.Sealed, snapshotFieldAAD(s.MemberID, s.Channel.ID, f.Key))
if err != nil {
return nil, false, fmt.Errorf("payout: reseal snapshot field %s: %w", f.Key, err)
}
if moved {
f.Sealed, changed = &next, true
}
}
if !changed {
return raw, false, nil
}
out, err := json.Marshal(s)
if err != nil {
return nil, false, fmt.Errorf("payout: reseal snapshot: %w", err)
}
return out, true, nil
}
func parseSnapshot(raw json.RawMessage) (SettlementSnapshot, error) {
var s SettlementSnapshot
if err := json.Unmarshal(raw, &s); err != nil {
return SettlementSnapshot{}, fmt.Errorf("payout: snapshot: %w", err)
}
if s.Version != SnapshotVersion {
return SettlementSnapshot{}, fmt.Errorf("payout: snapshot version %d, want %d", s.Version, SnapshotVersion)
}
return s, nil
}
// eligible applies spec 9.6's eligibility rules (Rulings P18, P19).
func eligible(c Channel, claimType string, amount money.Amount, currency string) error {
if amount.IsNegative() || amount.IsZero() {
return &IneligibleError{Rule: RuleAmount, Detail: "the amount must be positive"}
}
if !slices.Contains(c.AllowedCurrencies, currency) {
return &IneligibleError{Rule: RuleCurrency, Detail: fmt.Sprintf("the channel does not pay in %s", currency)}
}
if len(c.AllowedClaimTypes) > 0 && !slices.Contains(c.AllowedClaimTypes, claimType) {
return &IneligibleError{Rule: RuleClaimType, Detail: fmt.Sprintf("the channel does not pay %q claims", claimType)}
}
if c.MaxAmount != nil && amount.Cmp(*c.MaxAmount) > 0 {
return &IneligibleError{Rule: RuleMaxAmount, Detail: fmt.Sprintf("the channel pays at most %s", c.MaxAmount)}
}
return nil
}
  • Step 14: Run the package and the drift guards

Run: go test ./internal/platform/payout/... ./internal/kernel/db/ ./internal/kernel/layering/ ./internal/gen/ -count=1 Expected: PASS, including TestEveryTableIsTenantIsolated, TestEveryTenantForeignKeyIsCompositeOnOrgID, TestOnlyOperationalTablesGrantDeleteToTheAppRole, TestEveryTenantQueryCarriesAnExplicitOrgPredicate, the sqlc drift test.

  • Step 15: Sabotage each guard once, quote the red output, restore (a) In 00090, change CONSTRAINT payout_channel_key_uniq UNIQUE (org_id, key) to a bare UNIQUE (org_id, key) → TestPayoutConstraintsAreNamed red naming payout_channel.payout_channel_org_id_key_key. (b) Remove FORCE ROW LEVEL SECURITY from member_payout_profile → TestEveryTableIsTenantIsolated red. (c) Change member_payout_profile_channel_fk to FOREIGN KEY (channel_id) REFERENCES payout_channel (id) → TestEveryTenantForeignKeyIsCompositeOnOrgID red. (d) Delete AND c.org_id = $2 from GetDefinition → TestEveryTenantQueryCarriesAnExplicitOrgPredicate red. (e) Add 'crypto' to payout_channel_behaviour_valid → TestSettlementBehavioursMatchTheCheck red. (f) In Snapshot, set sf.Value = v for masked fields too → TestSnapshotFreezesChannelAndProfile red with no masked value in plaintext in the stored snapshot. (g) In seal.go, pass nil instead of aad to gcm.Seal → TestOpenRefusesATamperedSeal/… (the other-row case) and TestSnapshotRefusesATamperedOrMovedProfile red.

  • Step 16: Commit

Terminal window
git add db/migrations/00090_payout.sql internal/kernel/db/retention_test.go internal/gen/sqlc internal/platform/payout
git commit -m "Add payout tables, the payout keyring and the contract API: GetChannel, sealed Snapshot, OpenSnapshot"

Task 2: Channel definitions — create, update, list, and the §9.7 rules

Section titled “Task 2: Channel definitions — create, update, list, and the §9.7 rules”

Files:

  • Create: internal/platform/payout/define.go
  • Test: internal/platform/payout/define_test.go

Interfaces:

  • Consumes (Task 1): Definition, Field, Names, ValidateSchema, scanDefinition, channelColumns, validateNames, recordName, writeNames, keyPattern, kindChannel, mapWriteError, InvalidChannelError, InvalidSchemaError, ErrChannelNotFound. Plan 4: chart.GetAccount, chart.ListJournals, chart.ErrNotFound, company.Get, audit.Write.

  • Produces:

    type ChannelInput struct {
    Key string; Names Names; SettlementBehaviour string; CreditAccountID uuid.UUID; Fields []Field
    AllowedCurrencies, AllowedClaimTypes []string; MaxAmount *money.Amount
    RequiresSettlementProof bool; SortOrder int; Inactive bool
    }
    type ChannelPatch struct {
    Names *Names; CreditAccountID *uuid.UUID; Fields *[]Field
    AllowedCurrencies, AllowedClaimTypes *[]string; MaxAmount **money.Amount
    RequiresSettlementProof *bool; SortOrder *int; IsActive *bool
    }
    func CreateChannel(ctx context.Context, tx pgx.Tx, c clock.Clock, in ChannelInput) (Definition, error)
    func UpdateChannel(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, p ChannelPatch) (Definition, error)
    func ListChannels(ctx context.Context, tx pgx.Tx, activeOnly bool) ([]Definition, error) // by sort_order, key
    func CheckDefinition(ctx context.Context, tx pgx.Tx, d Definition) ([]string, error) // §9.7; nil = valid
  • Step 1: Write the failing tests (define_test.go)

func bankInput(e *payouttest.Env) payout.ChannelInput {
return payout.ChannelInput{Key: "bank_transfer", Names: payout.Names{"en": "Bank transfer", "ms": "Pindahan bank", "zh": "银行转账"},
SettlementBehaviour: payout.BehaviourExternal, CreditAccountID: e.Chart.Bank.ID,
Fields: payouttest.BankFields(), AllowedCurrencies: []string{"MYR"}, RequiresSettlementProof: true}
}
func create(e *payouttest.Env, in payout.ChannelInput) (payout.Definition, error) {
var d payout.Definition
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
var err error
d, err = payout.CreateChannel(ctx, tx, e.Clock, in)
return err
})
return d, err
}
func problems(t *testing.T, err error) string {
t.Helper()
var inv *payout.InvalidChannelError
require.ErrorAs(t, err, &inv)
return strings.Join(inv.Problems, "; ")
}
func TestCreateChannelWritesNamesAndAudit(t *testing.T) {
e := payouttest.New(t)
e.SeedRegistryEntry(t, "my_banks", "maybank", "Maybank")
d, err := create(e, bankInput(e))
require.NoError(t, err)
require.Equal(t, "Bank transfer", d.Name) // company default_locale en
var zh string
require.NoError(t, e.H.Admin.QueryRow(context.Background(), `SELECT value FROM translation
WHERE org_id = $1 AND entity_kind = 'payout_channel' AND entity_id = $2 AND locale = 'zh'`, e.Org, d.ID).Scan(&zh))
require.Equal(t, "银行转账", zh)
var audits int
require.NoError(t, e.H.Admin.QueryRow(context.Background(), `SELECT count(*) FROM audit_log
WHERE org_id = $1 AND entity_kind = 'payout_channel' AND entity_id = $2 AND action = 'create'`, e.Org, d.ID).Scan(&audits))
require.Equal(t, 1, audits)
}
func TestCreateChannelRefusesADuplicateKey(t *testing.T) {
e := payouttest.New(t)
e.SeedRegistryEntry(t, "my_banks", "maybank", "Maybank")
_, err := create(e, bankInput(e))
require.NoError(t, err) // PRESENCE
_, err = create(e, bankInput(e))
require.ErrorIs(t, err, payout.ErrDuplicateChannelKey)
}
func TestTheCreditAccountMustBeReachedThroughABankOrCashJournal(t *testing.T) {
e := payouttest.New(t)
e.SeedRegistryEntry(t, "my_banks", "maybank", "Maybank")
in := bankInput(e)
in.Key = "via_cash"
in.CreditAccountID = e.Chart.Cash.ID // CSH journal's default: accepted
_, err := create(e, in)
require.NoError(t, err)
for key, acct := range map[string]uuid.UUID{
"on_expense": e.Chart.Travel.ID, // not an asset, no journal
"on_receivable": e.Chart.AR.ID, // an asset no bank or cash journal fronts
"on_header": e.Chart.Assets.ID, // not postable
"on_unknown": uuid.New(),
} {
t.Run(key, func(t *testing.T) {
in := bankInput(e)
in.Key = key
in.CreditAccountID = acct
_, err := create(e, in)
require.Contains(t, problems(t, err), "credit")
})
}
}
func TestACurrencyRestrictedAccountBindsOnlyItsCurrency(t *testing.T) {
e := payouttest.New(t)
e.SeedRegistryEntry(t, "my_banks", "maybank", "Maybank")
usd := "USD"
bank := "bank"
var wise chart.Account
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
wise, err = chart.CreateAccount(ctx, tx, e.Clock, chart.AccountInput{ParentID: &e.Chart.Assets.ID,
Code: "1521", Name: "Wise USD", Type: chart.TypeAsset, ControlKind: &bank, Currency: &usd})
if err != nil {
return err
}
_, err = chart.CreateJournal(ctx, tx, e.Clock, chart.JournalInput{Code: "WIS", Name: "Wise",
Type: chart.JournalBank, DefaultAccountID: &wise.ID, Currency: &usd})
return err
})
in := bankInput(e)
in.Key, in.CreditAccountID, in.AllowedCurrencies = "wise_usd", wise.ID, []string{"USD"}
_, err := create(e, in)
require.NoError(t, err) // PRESENCE
in.Key, in.AllowedCurrencies = "wise_myr", []string{"MYR"}
_, err = create(e, in)
require.Contains(t, problems(t, err), "currency MYR")
}
func TestPettyCashChannelsAreConstrained(t *testing.T) {
e := payouttest.New(t)
e.SeedRegistryEntry(t, "my_banks", "maybank", "Maybank")
payee := payouttest.BankFields()[:1]
ok := payout.ChannelInput{Key: "petty_cash", Names: payout.Names{"en": "Petty cash", "ms": "Tunai runcit"},
SettlementBehaviour: payout.BehaviourPettyCash, CreditAccountID: e.Chart.Cash.ID,
Fields: payee, AllowedCurrencies: []string{"MYR"}}
_, err := create(e, ok)
require.NoError(t, err) // PRESENCE
bankFields := ok
bankFields.Key, bankFields.Fields = "pc_bank", payouttest.BankFields()
require.Contains(t, problems(t, mustErr(create(e, bankFields))), "petty_cash")
viaBank := ok
viaBank.Key, viaBank.CreditAccountID = "pc_via_bank", e.Chart.Bank.ID
require.Contains(t, problems(t, mustErr(create(e, viaBank))), "cash journal")
foreign := ok
foreign.Key, foreign.AllowedCurrencies = "pc_usd", []string{"USD"}
require.Contains(t, problems(t, mustErr(create(e, foreign))), "base currency")
}
func mustErr(_ payout.Definition, err error) error { return err }
func TestARegistryRefNeedsAnActiveEntry(t *testing.T) {
e := payouttest.New(t)
_, err := create(e, bankInput(e))
require.Contains(t, problems(t, err), "my_banks") // no entry yet
e.SeedRegistryEntry(t, "my_banks", "maybank", "Maybank")
_, err = create(e, bankInput(e))
require.NoError(t, err)
}
func TestAMaxAmountNeedsExactlyOneCurrency(t *testing.T) {
e := payouttest.New(t)
e.SeedRegistryEntry(t, "my_banks", "maybank", "Maybank")
limit := money.MustParse("500")
in := bankInput(e)
in.MaxAmount = &limit
_, err := create(e, in)
require.NoError(t, err) // PRESENCE: one currency
in.Key, in.AllowedCurrencies = "two_cur", []string{"MYR", "SGD"}
require.Contains(t, problems(t, mustErr(create(e, in))), "max_amount")
}
func TestUpdateChannelRetiresEvenWhenTheAccountNoLongerQualifies(t *testing.T) {
e := payouttest.New(t)
e.SeedRegistryEntry(t, "my_banks", "maybank", "Maybank")
d, err := create(e, bankInput(e))
require.NoError(t, err)
// The bank journal is retired: the channel's account no longer qualifies.
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
inactive := false
_, err := chart.UpdateJournal(ctx, tx, e.Clock, e.Journals.Bank.ID, chart.JournalPatch{IsActive: &inactive})
return err
})
sort := 5
err = e.TxErr(func(ctx context.Context, tx pgx.Tx) error { // active: the rules run
_, err := payout.UpdateChannel(ctx, tx, e.Clock, d.ID, payout.ChannelPatch{SortOrder: &sort})
return err
})
require.Contains(t, problems(t, err), "credit")
off := false
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { // retiring: shape only (Ruling P26)
got, err := payout.UpdateChannel(ctx, tx, e.Clock, d.ID, payout.ChannelPatch{IsActive: &off})
require.False(t, got.IsActive)
return err
})
}
func TestUpdateChannelAuditsBeforeAndAfterAndReplacesNames(t *testing.T) {
e := payouttest.New(t)
e.SeedRegistryEntry(t, "my_banks", "maybank", "Maybank")
d, err := create(e, bankInput(e))
require.NoError(t, err)
names := payout.Names{"en": "Bank (IBG)", "ms": "Bank (IBG)"} // zh dropped
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
got, err := payout.UpdateChannel(ctx, tx, e.Clock, d.ID, payout.ChannelPatch{Names: &names})
require.Equal(t, "Bank (IBG)", got.Name)
return err
})
var zh int
require.NoError(t, e.H.Admin.QueryRow(context.Background(), `SELECT count(*) FROM translation
WHERE org_id = $1 AND entity_id = $2 AND locale = 'zh'`, e.Org, d.ID).Scan(&zh))
require.Zero(t, zh)
var before, after string
require.NoError(t, e.H.Admin.QueryRow(context.Background(), `SELECT before->>'Name', after->>'Name' FROM audit_log
WHERE org_id = $1 AND entity_id = $2 AND action = 'update'`, e.Org, d.ID).Scan(&before, &after))
require.Equal(t, []string{"Bank transfer", "Bank (IBG)"}, []string{before, after})
}
func TestUpdateChannelOfAnotherOrgIsNotFound(t *testing.T) {
e := payouttest.New(t)
other := e.Other(t, "Other")
id := other.SeedChannel(t, payouttest.ChannelRow{Key: "cash", Name: "Cash", CreditAccountID: other.Chart.Cash.ID})
sort := 1
other.Tx(t, func(ctx context.Context, tx pgx.Tx) error { // PRESENCE
_, err := payout.UpdateChannel(ctx, tx, other.Clock, id, payout.ChannelPatch{SortOrder: &sort})
return err
})
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := payout.UpdateChannel(ctx, tx, e.Clock, id, payout.ChannelPatch{SortOrder: &sort})
return err
})
require.ErrorIs(t, err, payout.ErrChannelNotFound)
}
func TestListChannelsOrdersAndFilters(t *testing.T) {
e := payouttest.New(t)
e.SeedChannel(t, payouttest.ChannelRow{Key: "b", Name: "B", CreditAccountID: e.Chart.Cash.ID})
e.SeedChannel(t, payouttest.ChannelRow{Key: "a", Name: "A", CreditAccountID: e.Chart.Cash.ID})
e.SeedChannel(t, payouttest.ChannelRow{Key: "c", Name: "C", CreditAccountID: e.Chart.Cash.ID, Inactive: true})
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
all, err := payout.ListChannels(ctx, tx, false)
require.NoError(t, err)
require.Equal(t, []string{"a", "b", "c"}, keys(all))
active, err := payout.ListChannels(ctx, tx, true)
require.Equal(t, []string{"a", "b"}, keys(active))
return err
})
}
func keys(ds []payout.Definition) []string {
out := make([]string, len(ds))
for i, d := range ds {
out[i] = d.Key
}
return out
}
  • Step 2: Run and watch fail. go test ./internal/platform/payout/ -run 'Channel|Petty|Registry|Max|Credit|Currency' -count=1 → FAIL: undefined payout.CreateChannel.

  • Step 3: Implement define.go

package payout
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/kernel/money"
"github.com/mcom/erp/internal/kernel/tenancy"
"github.com/mcom/erp/internal/platform/accounting/chart"
"github.com/mcom/erp/internal/platform/company"
)
// ChannelInput is what CreateChannel takes. Key and SettlementBehaviour are
// fixed for life (Ruling P7), so ChannelPatch has no field for either.
type ChannelInput struct {
Key string
Names Names
SettlementBehaviour string
CreditAccountID uuid.UUID
Fields []Field
AllowedCurrencies []string
AllowedClaimTypes []string
MaxAmount *money.Amount
RequiresSettlementProof bool
SortOrder int
Inactive bool
}
// ChannelPatch: nil leaves a field alone. MaxAmount is a double pointer so
// that "clear the cap" (non-nil pointer to nil) is expressible.
type ChannelPatch struct {
Names *Names
CreditAccountID *uuid.UUID
Fields *[]Field
AllowedCurrencies *[]string
AllowedClaimTypes *[]string
MaxAmount **money.Amount
RequiresSettlementProof *bool
SortOrder *int
IsActive *bool
}
func CreateChannel(ctx context.Context, tx pgx.Tx, c clock.Clock, in ChannelInput) (Definition, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return Definition{}, fmt.Errorf("payout: create channel: %w", tenancy.ErrNoOrg)
}
profile, err := company.Get(ctx, tx)
if err != nil {
return Definition{}, fmt.Errorf("payout: create channel: %w", err)
}
d := Definition{
Channel: Channel{ID: uuid.New(), Key: in.Key, SettlementBehaviour: in.SettlementBehaviour,
CreditAccountID: in.CreditAccountID, AllowedCurrencies: in.AllowedCurrencies,
AllowedClaimTypes: orEmpty(in.AllowedClaimTypes), MaxAmount: in.MaxAmount,
RequiresSettlementProof: in.RequiresSettlementProof, IsActive: !in.Inactive},
Name: recordName(in.Names, profile.DefaultLocale), SortOrder: in.SortOrder, Fields: in.Fields,
}
if d.Fields == nil {
d.Fields = []Field{}
}
var problems []string
if !keyPattern.MatchString(in.Key) {
problems = append(problems, "key must be lower-case letters, digits and underscores, starting with a letter")
}
if p := validateNames(in.Names); p != "" {
problems = append(problems, "names "+p)
}
more, err := CheckDefinition(ctx, tx, d)
if err != nil {
return Definition{}, err
}
if problems = append(problems, more...); len(problems) > 0 {
return Definition{}, &InvalidChannelError{Problems: problems}
}
schema, err := json.Marshal(d.Fields)
if err != nil {
return Definition{}, fmt.Errorf("payout: create channel: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO payout_channel (id, org_id, key, name, is_active, sort_order, settlement_behaviour,
credit_account_id, field_schema, allowed_currencies, allowed_claim_types,
max_amount, requires_settlement_proof)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::numeric,$13)`,
d.ID, orgID, d.Key, d.Name, d.IsActive, d.SortOrder, d.SettlementBehaviour, d.CreditAccountID,
schema, d.AllowedCurrencies, d.AllowedClaimTypes, amountArg(d.MaxAmount), d.RequiresSettlementProof); err != nil {
return Definition{}, mapWriteError(fmt.Errorf("payout: create channel: %w", err))
}
if err := writeNames(ctx, tx, orgID, kindChannel, d.ID, in.Names); err != nil {
return Definition{}, err
}
if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: kindChannel, EntityID: d.ID, Action: "create", After: d}); err != nil {
return Definition{}, fmt.Errorf("payout: create channel: %w", err)
}
return d, nil
}
func UpdateChannel(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, p ChannelPatch) (Definition, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return Definition{}, fmt.Errorf("payout: update channel: %w", tenancy.ErrNoOrg)
}
// The row lock comes first, before any audit.Write (wave-2 §3).
before, err := scanDefinition(tx.QueryRow(ctx,
`SELECT `+channelColumns+` FROM payout_channel c WHERE c.id = $1 AND c.org_id = $2 FOR UPDATE`, id, orgID))
if errors.Is(err, pgx.ErrNoRows) {
return Definition{}, ErrChannelNotFound
}
if err != nil {
return Definition{}, fmt.Errorf("payout: update channel: %w", err)
}
after := before
after.AllowedCurrencies = slices.Clone(before.AllowedCurrencies)
after.AllowedClaimTypes = slices.Clone(before.AllowedClaimTypes)
after.Fields = slices.Clone(before.Fields)
var problems []string
if p.Names != nil {
if msg := validateNames(*p.Names); msg != "" {
problems = append(problems, "names "+msg)
}
profile, err := company.Get(ctx, tx)
if err != nil {
return Definition{}, fmt.Errorf("payout: update channel: %w", err)
}
after.Name = recordName(*p.Names, profile.DefaultLocale)
}
if p.CreditAccountID != nil {
after.CreditAccountID = *p.CreditAccountID
}
if p.Fields != nil {
after.Fields = *p.Fields
}
if p.AllowedCurrencies != nil {
after.AllowedCurrencies = *p.AllowedCurrencies
}
if p.AllowedClaimTypes != nil {
after.AllowedClaimTypes = orEmpty(*p.AllowedClaimTypes)
}
if p.MaxAmount != nil {
after.MaxAmount = *p.MaxAmount
}
if p.RequiresSettlementProof != nil {
after.RequiresSettlementProof = *p.RequiresSettlementProof
}
if p.SortOrder != nil {
after.SortOrder = *p.SortOrder
}
if p.IsActive != nil {
after.IsActive = *p.IsActive
}
var more []string
if after.IsActive {
more, err = CheckDefinition(ctx, tx, after) // Ruling P26
if err != nil {
return Definition{}, err
}
} else {
more = shapeProblems(after)
}
if problems = append(problems, more...); len(problems) > 0 {
return Definition{}, &InvalidChannelError{Problems: problems}
}
schema, err := json.Marshal(after.Fields)
if err != nil {
return Definition{}, fmt.Errorf("payout: update channel: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE payout_channel
SET name = $1, is_active = $2, sort_order = $3, credit_account_id = $4, field_schema = $5,
allowed_currencies = $6, allowed_claim_types = $7, max_amount = $8::numeric,
requires_settlement_proof = $9, updated_at = now()
WHERE id = $10 AND org_id = $11`,
after.Name, after.IsActive, after.SortOrder, after.CreditAccountID, schema, after.AllowedCurrencies,
after.AllowedClaimTypes, amountArg(after.MaxAmount), after.RequiresSettlementProof, id, orgID); err != nil {
return Definition{}, mapWriteError(fmt.Errorf("payout: update channel: %w", err))
}
if p.Names != nil {
if err := writeNames(ctx, tx, orgID, kindChannel, id, *p.Names); err != nil {
return Definition{}, err
}
}
if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: kindChannel, EntityID: id, Action: "update",
Before: before, After: after}); err != nil {
return Definition{}, fmt.Errorf("payout: update channel: %w", err)
}
return after, nil
}
func ListChannels(ctx context.Context, tx pgx.Tx, activeOnly bool) ([]Definition, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return nil, fmt.Errorf("payout: list channels: %w", tenancy.ErrNoOrg)
}
rows, err := tx.Query(ctx, `SELECT `+channelColumns+` FROM payout_channel c
WHERE c.org_id = $1 AND (c.is_active OR NOT $2) ORDER BY c.sort_order, c.key`, orgID, activeOnly)
if err != nil {
return nil, fmt.Errorf("payout: list channels: %w", err)
}
defer rows.Close()
out := []Definition{}
for rows.Next() {
d, err := scanDefinition(rows)
if err != nil {
return nil, fmt.Errorf("payout: list channels: %w", err)
}
out = append(out, d)
}
return out, rows.Err()
}
// CheckDefinition answers every way d breaks spec 9.7 or Rulings P5, P6,
// P18, P19 — nil when it breaks none. The provisioning step reports these as
// readiness warnings for active channels (Task 5).
func CheckDefinition(ctx context.Context, tx pgx.Tx, d Definition) ([]string, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return nil, fmt.Errorf("payout: check channel: %w", tenancy.ErrNoOrg)
}
p := shapeProblems(d)
// Rule 1 (Ruling P5): an active postable account fronted by an active
// bank or cash journal.
var through *chart.Journal
acct, err := chart.GetAccount(ctx, tx, d.CreditAccountID)
switch {
case errors.Is(err, chart.ErrNotFound):
p = append(p, "credit_account_id names no account in this organization")
case err != nil:
return nil, err
default:
if !acct.IsActive || !acct.IsPostable {
p = append(p, "the credit account must be active and postable")
}
journals, err := chart.ListJournals(ctx, tx)
if err != nil {
return nil, err
}
for i := range journals {
j := journals[i]
if j.IsActive && (j.Type == chart.JournalBank || j.Type == chart.JournalCash) &&
j.DefaultAccountID != nil && *j.DefaultAccountID == acct.ID {
through = &j
break
}
}
if through == nil {
p = append(p, "the credit account must be the default account of an active bank or cash journal")
}
for _, cur := range d.AllowedCurrencies {
if acct.Currency != nil && *acct.Currency != cur {
p = append(p, fmt.Sprintf("currency %s is not the credit account's currency %s", cur, *acct.Currency))
}
if through != nil && through.Currency != nil && *through.Currency != cur {
p = append(p, fmt.Sprintf("currency %s is not the %s journal's currency %s", cur, through.Code, *through.Currency))
}
}
}
// Rule 2 (Ruling P6).
if d.SettlementBehaviour == BehaviourPettyCash {
if through != nil && through.Type != chart.JournalCash {
p = append(p, "a petty_cash channel must credit through a cash journal")
}
for _, f := range d.Fields {
if f.Type == FieldAccountNo || f.Type == FieldRegistryRef {
p = append(p, "a petty_cash channel cannot ask for bank details ("+f.Key+")")
}
}
profile, err := company.Get(ctx, tx)
if err != nil {
return nil, err
}
if !slices.Equal(d.AllowedCurrencies, []string{profile.BaseCurrency}) {
p = append(p, "a petty_cash channel pays in the base currency "+profile.BaseCurrency+" only")
}
}
// Rule 3.
for _, f := range d.Fields {
if f.Type != FieldRegistryRef {
continue
}
var hasEntry bool
if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM payout_registry_entry r
WHERE r.org_id = $1 AND r.registry = $2 AND r.is_active)`, orgID, f.Registry).Scan(&hasEntry); err != nil {
return nil, fmt.Errorf("payout: check channel: %w", err)
}
if !hasEntry {
p = append(p, fmt.Sprintf("field %s: registry %s has no active entry", f.Key, f.Registry))
}
}
return p, nil
}
// shapeProblems is everything decidable without the database.
func shapeProblems(d Definition) []string {
var p []string
if !slices.Contains(SettlementBehaviours, d.SettlementBehaviour) {
p = append(p, `settlement_behaviour must be "external" or "petty_cash"`)
}
var schema *InvalidSchemaError
if err := ValidateSchema(d.Fields); errors.As(err, &schema) {
p = append(p, schema.Problems...)
}
if len(d.AllowedCurrencies) == 0 {
p = append(p, "allowed_currencies needs at least one currency")
}
seen := map[string]bool{}
for _, cur := range d.AllowedCurrencies {
if !currencyPattern.MatchString(cur) || seen[cur] {
p = append(p, "allowed_currencies must be distinct ISO 4217 codes")
}
seen[cur] = true
}
seen = map[string]bool{}
for _, k := range d.AllowedClaimTypes {
if !keyPattern.MatchString(k) || seen[k] {
p = append(p, "allowed_claim_types must be distinct lower-case keys")
}
seen[k] = true
}
if d.MaxAmount != nil {
if d.MaxAmount.IsNegative() || d.MaxAmount.IsZero() {
p = append(p, "max_amount must be positive")
}
if len(d.AllowedCurrencies) != 1 {
p = append(p, "a max_amount needs exactly one allowed currency (Ruling P18)")
}
}
return p
}
var currencyPattern = regexp.MustCompile(`^[A-Z]{3}$`)
func orEmpty(s []string) []string {
if s == nil {
return []string{}
}
return s
}
// amountArg binds a nullable money column as its decimal text.
func amountArg(a *money.Amount) any {
if a == nil {
return nil
}
return a.String()
}

(Add "regexp" to the import block.)

  • Step 4: Run. go test ./internal/platform/payout/ -count=1 → PASS. Then go test ./internal/kernel/layering/ -count=1 → PASS.

  • Step 5: Commit

Terminal window
git add internal/platform/payout/define.go internal/platform/payout/define_test.go
git commit -m "Payout channel definitions with the spec 9.7 rules"

Task 3: Payout registries — create, retire, list

Section titled “Task 3: Payout registries — create, retire, list”

Files:

  • Create: internal/platform/payout/registry.go
  • Test: internal/platform/payout/registry_test.go

Interfaces:

  • Consumes (Task 1): RegistryEntry, scanRegistryEntry, registryColumns, Names, validateNames, recordName, writeNames, keyPattern, kindRegistryEntry, mapWriteError, ErrRegistryEntryNotFound, ErrRegistryInUse, ErrDuplicateRegistryKey, InvalidChannelError (reused for input problems — see below). Plan 4: company.Get, audit.Write.

  • Produces:

    type RegistryEntryInput struct { Registry, Key string; Names Names; SortOrder int }
    type RegistryEntryPatch struct { Names *Names; SortOrder *int; IsActive *bool }
    func CreateRegistryEntry(ctx context.Context, tx pgx.Tx, c clock.Clock, in RegistryEntryInput) (RegistryEntry, error)
    func UpdateRegistryEntry(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, p RegistryEntryPatch) (RegistryEntry, error)
    func GetRegistryEntry(ctx context.Context, tx pgx.Tx, id uuid.UUID) (RegistryEntry, error)
    func ListRegistryEntries(ctx context.Context, tx pgx.Tx, registry string, activeOnly bool) ([]RegistryEntry, error) // registry "" = all; by registry, sort_order, key

    Input problems (bad registry/key format, bad names) are *InvalidChannelError — a registry entry is part of a channel’s definition surface, and one 422 type keeps the module’s mapping flat.

  • Step 1: Write the failing tests (registry_test.go)

func entryInput(key string) payout.RegistryEntryInput {
return payout.RegistryEntryInput{Registry: "my_banks", Key: key, Names: payout.Names{"en": "Bank " + key, "ms": "Bank " + key}}
}
func TestCreateRegistryEntryAndDuplicate(t *testing.T) {
e := payouttest.New(t)
var got payout.RegistryEntry
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
got, err = payout.CreateRegistryEntry(ctx, tx, e.Clock, entryInput("maybank"))
return err
})
require.Equal(t, "Bank maybank", got.Name)
require.True(t, got.IsActive)
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := payout.CreateRegistryEntry(ctx, tx, e.Clock, entryInput("maybank"))
return err
})
require.ErrorIs(t, err, payout.ErrDuplicateRegistryKey)
err = e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := payout.CreateRegistryEntry(ctx, tx, e.Clock, entryInput("Bad Key"))
return err
})
var inv *payout.InvalidChannelError
require.ErrorAs(t, err, &inv)
}
func TestRetiringTheLastEntryAnActiveChannelUsesIsRefused(t *testing.T) {
e := payouttest.New(t)
ch := e.BankChannel(t) // my_banks/maybank + active bank_transfer
var cimb payout.RegistryEntry
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
cimb, err = payout.CreateRegistryEntry(ctx, tx, e.Clock, entryInput("cimb"))
return err
})
off := false
retire := func(id uuid.UUID) error {
return e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := payout.UpdateRegistryEntry(ctx, tx, e.Clock, id, payout.RegistryEntryPatch{IsActive: &off})
return err
})
}
require.NoError(t, retire(cimb.ID)) // PRESENCE: one other active entry remains
var maybank uuid.UUID
require.NoError(t, e.H.Admin.QueryRow(context.Background(),
`SELECT id FROM payout_registry_entry WHERE org_id = $1 AND key = 'maybank'`, e.Org).Scan(&maybank))
require.ErrorIs(t, retire(maybank), payout.ErrRegistryInUse)
// Once the channel is retired, so may the entry be.
_, err := e.H.Admin.Exec(context.Background(), `UPDATE payout_channel SET is_active = false WHERE id = $1`, ch)
require.NoError(t, err)
require.NoError(t, retire(maybank))
}
func TestConcurrentRetirementsCannotEmptyAUsedRegistry(t *testing.T) {
e := payouttest.New(t)
e.BankChannel(t)
var cimb payout.RegistryEntry
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
cimb, err = payout.CreateRegistryEntry(ctx, tx, e.Clock, entryInput("cimb"))
return err
})
var maybank uuid.UUID
require.NoError(t, e.H.Admin.QueryRow(context.Background(),
`SELECT id FROM payout_registry_entry WHERE org_id = $1 AND key = 'maybank'`, e.Org).Scan(&maybank))
ctx, cancel := context.WithTimeout(e.Ctx, 20*time.Second)
t.Cleanup(cancel)
off := false
errs := make(chan error, 2)
for _, id := range []uuid.UUID{maybank, cimb.ID} {
go func() {
errs <- e.DB.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
_, err := payout.UpdateRegistryEntry(ctx, tx, e.Clock, id, payout.RegistryEntryPatch{IsActive: &off})
return err
})
}()
}
var ok, inUse int
for range 2 {
select {
case err := <-errs:
switch {
case err == nil:
ok++
case errors.Is(err, payout.ErrRegistryInUse):
inUse++
default:
t.Fatalf("unexpected: %v", err)
}
case <-ctx.Done():
t.Fatal("a retirement never returned: lock order is wrong")
}
}
require.Equal(t, [2]int{1, 1}, [2]int{ok, inUse})
}
func TestListRegistryEntriesFiltersAndOrders(t *testing.T) {
e := payouttest.New(t)
e.SeedRegistryEntry(t, "my_banks", "rhb", "RHB")
e.SeedRegistryEntry(t, "my_banks", "cimb", "CIMB")
e.SeedRegistryEntry(t, "my_ewallets", "grabpay", "GrabPay")
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
banks, err := payout.ListRegistryEntries(ctx, tx, "my_banks", true)
require.NoError(t, err)
require.Equal(t, "cimb", banks[0].Key)
require.Len(t, banks, 2)
all, err := payout.ListRegistryEntries(ctx, tx, "", false)
require.Len(t, all, 3)
return err
})
}
  • Step 2: Run and watch fail. go test ./internal/platform/payout/ -run Registry -count=1 → FAIL: undefined payout.CreateRegistryEntry.

  • Step 3: Implement registry.go

package payout
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/kernel/tenancy"
"github.com/mcom/erp/internal/platform/company"
)
type RegistryEntryInput struct {
Registry, Key string
Names Names
SortOrder int
}
// RegistryEntryPatch: Registry and Key are fixed for life (Ruling P7).
type RegistryEntryPatch struct {
Names *Names
SortOrder *int
IsActive *bool
}
func CreateRegistryEntry(ctx context.Context, tx pgx.Tx, c clock.Clock, in RegistryEntryInput) (RegistryEntry, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return RegistryEntry{}, fmt.Errorf("payout: create registry entry: %w", tenancy.ErrNoOrg)
}
var problems []string
if !keyPattern.MatchString(in.Registry) {
problems = append(problems, "registry must be a lower-case key")
}
if !optionPattern.MatchString(in.Key) {
problems = append(problems, "key must be lower-case letters, digits and underscores")
}
if msg := validateNames(in.Names); msg != "" {
problems = append(problems, "names "+msg)
}
if len(problems) > 0 {
return RegistryEntry{}, &InvalidChannelError{Problems: problems}
}
profile, err := company.Get(ctx, tx)
if err != nil {
return RegistryEntry{}, fmt.Errorf("payout: create registry entry: %w", err)
}
e := RegistryEntry{ID: uuid.New(), Registry: in.Registry, Key: in.Key,
Name: recordName(in.Names, profile.DefaultLocale), IsActive: true, SortOrder: in.SortOrder}
if _, err := tx.Exec(ctx, `
INSERT INTO payout_registry_entry (id, org_id, registry, key, name, sort_order)
VALUES ($1,$2,$3,$4,$5,$6)`, e.ID, orgID, e.Registry, e.Key, e.Name, e.SortOrder); err != nil {
return RegistryEntry{}, mapWriteError(fmt.Errorf("payout: create registry entry: %w", err))
}
if err := writeNames(ctx, tx, orgID, kindRegistryEntry, e.ID, in.Names); err != nil {
return RegistryEntry{}, err
}
if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: kindRegistryEntry, EntityID: e.ID,
Action: "create", After: e}); err != nil {
return RegistryEntry{}, fmt.Errorf("payout: create registry entry: %w", err)
}
return e, nil
}
func GetRegistryEntry(ctx context.Context, tx pgx.Tx, id uuid.UUID) (RegistryEntry, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return RegistryEntry{}, fmt.Errorf("payout: get registry entry: %w", tenancy.ErrNoOrg)
}
e, err := scanRegistryEntry(tx.QueryRow(ctx,
`SELECT `+registryColumns+` FROM payout_registry_entry r WHERE r.id = $1 AND r.org_id = $2`, id, orgID))
if errors.Is(err, pgx.ErrNoRows) {
return RegistryEntry{}, ErrRegistryEntryNotFound
}
if err != nil {
return RegistryEntry{}, fmt.Errorf("payout: get registry entry: %w", err)
}
return e, nil
}
// UpdateRegistryEntry locks every active entry of the registry in id order
// (Ruling P25) before deciding whether a retirement empties a registry an
// active channel uses — two concurrent retirements then serialize instead of
// each seeing the other's entry still active.
func UpdateRegistryEntry(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, p RegistryEntryPatch) (RegistryEntry, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return RegistryEntry{}, fmt.Errorf("payout: update registry entry: %w", tenancy.ErrNoOrg)
}
probe, err := GetRegistryEntry(ctx, tx, id)
if err != nil {
return RegistryEntry{}, err
}
if _, err := tx.Exec(ctx, `SELECT r.id FROM payout_registry_entry r
WHERE r.org_id = $1 AND r.registry = $2 AND (r.is_active OR r.id = $3) ORDER BY r.id FOR UPDATE`,
orgID, probe.Registry, id); err != nil {
return RegistryEntry{}, fmt.Errorf("payout: update registry entry: lock: %w", err)
}
before, err := GetRegistryEntry(ctx, tx, id) // re-read under the lock
if err != nil {
return RegistryEntry{}, err
}
after := before
if p.Names != nil {
if msg := validateNames(*p.Names); msg != "" {
return RegistryEntry{}, &InvalidChannelError{Problems: []string{"names " + msg}}
}
profile, err := company.Get(ctx, tx)
if err != nil {
return RegistryEntry{}, fmt.Errorf("payout: update registry entry: %w", err)
}
after.Name = recordName(*p.Names, profile.DefaultLocale)
}
if p.SortOrder != nil {
after.SortOrder = *p.SortOrder
}
if p.IsActive != nil {
after.IsActive = *p.IsActive
}
if before.IsActive && !after.IsActive {
var others int
var used bool
if err := tx.QueryRow(ctx, `SELECT count(*) FROM payout_registry_entry r
WHERE r.org_id = $1 AND r.registry = $2 AND r.is_active AND r.id <> $3`,
orgID, before.Registry, id).Scan(&others); err != nil {
return RegistryEntry{}, fmt.Errorf("payout: update registry entry: %w", err)
}
if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM payout_channel c
WHERE c.org_id = $1 AND c.is_active
AND c.field_schema @> jsonb_build_array(jsonb_build_object('type', 'registry_ref', 'registry', $2::text)))`,
orgID, before.Registry).Scan(&used); err != nil {
return RegistryEntry{}, fmt.Errorf("payout: update registry entry: %w", err)
}
if others == 0 && used {
return RegistryEntry{}, ErrRegistryInUse
}
}
if _, err := tx.Exec(ctx, `UPDATE payout_registry_entry
SET name = $1, sort_order = $2, is_active = $3, updated_at = now() WHERE id = $4 AND org_id = $5`,
after.Name, after.SortOrder, after.IsActive, id, orgID); err != nil {
return RegistryEntry{}, fmt.Errorf("payout: update registry entry: %w", err)
}
if p.Names != nil {
if err := writeNames(ctx, tx, orgID, kindRegistryEntry, id, *p.Names); err != nil {
return RegistryEntry{}, err
}
}
if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: kindRegistryEntry, EntityID: id,
Action: "update", Before: before, After: after}); err != nil {
return RegistryEntry{}, fmt.Errorf("payout: update registry entry: %w", err)
}
return after, nil
}
func ListRegistryEntries(ctx context.Context, tx pgx.Tx, registry string, activeOnly bool) ([]RegistryEntry, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return nil, fmt.Errorf("payout: list registry entries: %w", tenancy.ErrNoOrg)
}
rows, err := tx.Query(ctx, `SELECT `+registryColumns+` FROM payout_registry_entry r
WHERE r.org_id = $1 AND ($2 = '' OR r.registry = $2) AND (r.is_active OR NOT $3)
ORDER BY r.registry, r.sort_order, r.key`, orgID, registry, activeOnly)
if err != nil {
return nil, fmt.Errorf("payout: list registry entries: %w", err)
}
defer rows.Close()
out := []RegistryEntry{}
for rows.Next() {
e, err := scanRegistryEntry(rows)
if err != nil {
return nil, fmt.Errorf("payout: list registry entries: %w", err)
}
out = append(out, e)
}
return out, rows.Err()
}
  • Step 4: Run. go test ./internal/platform/payout/ -run Registry -count=1 -race → PASS. Sabotage: drop FOR UPDATE from the lock statement, run TestConcurrentRetirementsCannotEmptyAUsedRegistry a few times with -count=5 until it reports [2 0], quote it, restore.

  • Step 5: Commit

Terminal window
git add internal/platform/payout/registry.go internal/platform/payout/registry_test.go
git commit -m "Payout registries: create, retire, list"

Task 4: Member payout profiles — save, delete, list, masked views, sealed storage, reseal

Section titled “Task 4: Member payout profiles — save, delete, list, masked views, sealed storage, reseal”

Files:

  • Create: internal/platform/payout/profile.go
  • Test: internal/platform/payout/profile_test.go

Interfaces:

  • Consumes (Task 1): Field, Names, ValidateDetails, display, scanDefinition, channelColumns, ErrChannelNotFound, ErrChannelInactive, ErrMemberNotFound, ErrNoProfile, InvalidDetailsError, FieldProblem, Sealed, SealDetails, openDetails, profileAAD, reseal, currentKeyring; payouttest.NewKey, UseKeyring, Env.Key. Plan 4/kernel: audit.Write.

  • Produces:

    type FieldView struct {
    Key, Type string; Label Names; Sensitivity string
    Value *string // nil for a masked field — never populated from a raw masked value
    Display string
    }
    type ProfileView struct {
    ID, ChannelID uuid.UUID; ChannelKey string; Fields []FieldView
    Valid bool; Problems []FieldProblem // against the channel's CURRENT schema
    UpdatedAt time.Time
    }
    func SaveProfile(ctx context.Context, tx pgx.Tx, c clock.Clock, memberID, channelID uuid.UUID,
    details map[string]string) (ProfileView, error)
    func DeleteProfile(ctx context.Context, tx pgx.Tx, c clock.Clock, memberID, channelID uuid.UUID) error
    func ListProfiles(ctx context.Context, tx pgx.Tx, memberID uuid.UUID) ([]ProfileView, error) // by channel sort_order, key
    func ResealProfiles(ctx context.Context, tx pgx.Tx, c clock.Clock) (int, error) // Ruling P32
    const kindProfile = "member_payout_profile"
    // unexported, used by Task 9:
    type auditView struct { MemberID, ChannelID uuid.UUID; ChannelKey string; Fields map[string]string
    Changed []string; Unreadable bool }
    func auditOf(ctx context.Context, tx pgx.Tx, d Definition, memberID uuid.UUID, values map[string]string) (auditView, error)
  • Step 1: Write the failing tests (profile_test.go)

var good = map[string]string{"payee_name": "Aisyah binti Ali", "bank": "maybank", "account_no": "5140 1234 5678"}
func save(e *payouttest.Env, member, ch uuid.UUID, d map[string]string) (payout.ProfileView, error) {
var v payout.ProfileView
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
var err error
v, err = payout.SaveProfile(ctx, tx, e.Clock, member, ch, d)
return err
})
return v, err
}
func TestSaveProfileStoresNormalizedAndReturnsMasked(t *testing.T) {
e := payouttest.New(t)
ch := e.BankChannel(t)
v, err := save(e, e.Member, ch, good)
require.NoError(t, err)
require.True(t, v.Valid)
acct := v.Fields[2]
require.Nil(t, acct.Value, "a masked field's value must never be returned")
require.Equal(t, "****5678", acct.Display)
require.Equal(t, "Maybank", v.Fields[1].Display)
require.Equal(t, "Aisyah binti Ali", *v.Fields[0].Value)
// Stored normalized: the snapshot opens the saved value as normalized.
var raw json.RawMessage
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
raw, err = payout.Snapshot(ctx, tx, ch, e.Member, "expense_claim", money.MustParse("1"), "MYR")
return err
})
opened, err := payout.OpenSnapshot(raw)
require.NoError(t, err)
require.Equal(t, "514012345678", opened.Fields[2].Value)
}
// TestStoredDetailsAreCiphertext is Ruling P15's guard: nothing the member
// typed is in the row, in any column, in any form the DB can show.
func TestStoredDetailsAreCiphertext(t *testing.T) {
e := payouttest.New(t)
ch := e.BankChannel(t)
_, err := save(e, e.Member, ch, good)
require.NoError(t, err)
var (
row, kid string
ct []byte
)
require.NoError(t, e.H.Admin.QueryRow(context.Background(), `
SELECT to_jsonb(p)::text, p.details_kid, p.details_ct FROM member_payout_profile p
WHERE p.org_id = $1 AND p.member_id = $2`, e.Org, e.Member).Scan(&row, &kid, &ct))
require.Equal(t, e.Key.ID, kid) // PRESENCE: the row exists, sealed under the active key
// "Aisyah" and "maybank" hold non-hex letters, so a hex-encoded bytea
// column cannot contain them by chance; the account number is checked
// against the raw ciphertext bytes as well.
for _, plain := range []string{"514012345678", "Aisyah", "maybank"} {
require.NotContains(t, row, plain)
require.False(t, bytes.Contains(ct, []byte(plain)), "ciphertext holds %q", plain)
}
}
// TestProfilesSurviveKeyRotationAndReseal is Ruling P32 for saved profiles.
func TestProfilesSurviveKeyRotationAndReseal(t *testing.T) {
e := payouttest.New(t)
ch := e.BankChannel(t)
_, err := save(e, e.Member, ch, good)
require.NoError(t, err)
bala := e.SeedMember(t, "Bala")
_, err = save(e, bala, ch, map[string]string{"payee_name": "Bala", "bank": "maybank", "account_no": "998877665544"})
require.NoError(t, err)
kids := func() map[string]int {
rows, err := e.H.Admin.Query(context.Background(),
`SELECT details_kid, count(*) FROM member_payout_profile WHERE org_id = $1 GROUP BY details_kid`, e.Org)
require.NoError(t, err)
defer rows.Close()
out := map[string]int{}
for rows.Next() {
var k string
var n int
require.NoError(t, rows.Scan(&k, &n))
out[k] = n
}
require.NoError(t, rows.Err())
return out
}
require.Equal(t, map[string]int{"test-1": 2}, kids()) // PRESENCE
next := payouttest.NewKey(t, "test-2")
payouttest.UseKeyring(t, next, &e.Key)
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { // the previous key still opens
vs, err := payout.ListProfiles(ctx, tx, e.Member)
require.NoError(t, err)
require.Equal(t, "****5678", vs[0].Fields[2].Display)
return err
})
_, err = save(e, e.Member, ch, good) // a save writes under the active key
require.NoError(t, err)
require.Equal(t, map[string]int{"test-1": 1, "test-2": 1}, kids())
var n int
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
n, err = payout.ResealProfiles(ctx, tx, e.Clock)
return err
})
require.Equal(t, 1, n)
require.Equal(t, map[string]int{"test-2": 2}, kids())
var before, after string
require.NoError(t, e.H.Admin.QueryRow(context.Background(), `
SELECT before::text, after::text FROM audit_log
WHERE org_id = $1 AND entity_kind = 'member_payout_profile' AND action = 'reseal'`, e.Org).Scan(&before, &after))
require.Contains(t, before, "test-1")
require.Contains(t, after, "test-2")
require.NotContains(t, before+after, "998877665544")
require.NotContains(t, before+after, "Bala")
payouttest.UseKeyring(t, next, nil) // the previous key is dropped: everything still opens
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
vs, err := payout.ListProfiles(ctx, tx, bala)
require.NoError(t, err)
require.Len(t, vs, 1)
require.Equal(t, "****5544", vs[0].Fields[2].Display)
return err
})
}
// TestAuditNeverHoldsARawSensitiveValue is Ruling P13's guard.
func TestAuditNeverHoldsARawSensitiveValue(t *testing.T) {
e := payouttest.New(t)
ch := e.BankChannel(t)
_, err := save(e, e.Member, ch, good)
require.NoError(t, err)
changed := maps.Clone(good)
changed["account_no"] = "8888 7777 6666"
_, err = save(e, e.Member, ch, changed)
require.NoError(t, err)
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
return payout.DeleteProfile(ctx, tx, e.Clock, e.Member, ch)
})
rows, err := e.H.Admin.Query(context.Background(), `SELECT action, coalesce(before::text,''), coalesce(after::text,'')
FROM audit_log WHERE org_id = $1 AND entity_kind = 'member_payout_profile' ORDER BY occurred_at`, e.Org)
require.NoError(t, err)
defer rows.Close()
var actions []string
for rows.Next() {
var action, before, after string
require.NoError(t, rows.Scan(&action, &before, &after))
actions = append(actions, action)
for _, raw := range []string{"514012345678", "888877776666"} {
require.NotContains(t, before+after, raw, "audit row %s holds a raw account number", action)
}
if action == "update" {
require.Contains(t, after, "****6666")
require.Contains(t, after, `"account_no"`) // the changed-keys list names it
}
}
require.NoError(t, rows.Err())
require.Equal(t, []string{"create", "update", "delete"}, actions) // PRESENCE: the rows exist
}
func TestSaveProfileRefusals(t *testing.T) {
e := payouttest.New(t)
ch := e.BankChannel(t)
_, err := save(e, e.Member, ch, good)
require.NoError(t, err) // PRESENCE
bad := maps.Clone(good)
bad["nric"] = "900101145678"
_, err = save(e, e.Member, ch, bad)
var invalid *payout.InvalidDetailsError
require.ErrorAs(t, err, &invalid)
_, err = save(e, uuid.New(), ch, good)
require.ErrorIs(t, err, payout.ErrMemberNotFound)
gone := e.SeedMember(t, "Gone")
_, err = e.H.Admin.Exec(context.Background(), `UPDATE organization_member SET active = false WHERE id = $1`, gone)
require.NoError(t, err)
_, err = save(e, gone, ch, good)
require.ErrorIs(t, err, payout.ErrMemberNotFound)
_, err = save(e, e.Member, uuid.New(), good)
require.ErrorIs(t, err, payout.ErrChannelNotFound)
retired := e.SeedChannel(t, payouttest.ChannelRow{Key: "old", Name: "Old", CreditAccountID: e.Chart.Bank.ID,
Fields: payouttest.BankFields(), Inactive: true})
_, err = save(e, e.Member, retired, good)
require.ErrorIs(t, err, payout.ErrChannelInactive)
}
func TestListProfilesReportsProfilesTheSchemaHasOutgrown(t *testing.T) {
e := payouttest.New(t)
ch := e.BankChannel(t)
_, err := save(e, e.Member, ch, good)
require.NoError(t, err)
fields := append(payouttest.BankFields(), payout.Field{Key: "branch", Type: payout.FieldText, Required: true,
Label: payout.Names{"en": "Branch", "ms": "Cawangan"}})
schema, _ := json.Marshal(fields)
_, err = e.H.Admin.Exec(context.Background(), `UPDATE payout_channel SET field_schema = $1 WHERE id = $2`, schema, ch)
require.NoError(t, err)
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
vs, err := payout.ListProfiles(ctx, tx, e.Member)
require.NoError(t, err)
require.Len(t, vs, 1)
require.False(t, vs[0].Valid)
require.Equal(t, []payout.FieldProblem{{Key: "branch", Reason: "required"}}, vs[0].Problems)
for _, f := range vs[0].Fields {
if f.Key == "account_no" {
require.Nil(t, f.Value)
}
}
return nil
})
}
func TestProfilesOfAnotherOrgAreInvisible(t *testing.T) {
e := payouttest.New(t)
other := e.Other(t, "Other")
och := other.BankChannel(t)
other.SeedProfile(t, other.Member, och, map[string]string{"payee_name": "B", "bank": "maybank", "account_no": "514012345678"})
other.Tx(t, func(ctx context.Context, tx pgx.Tx) error { // PRESENCE
vs, err := payout.ListProfiles(ctx, tx, other.Member)
require.Len(t, vs, 1)
return err
})
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
vs, err := payout.ListProfiles(ctx, tx, other.Member)
require.Empty(t, vs)
return err
})
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
return payout.DeleteProfile(ctx, tx, e.Clock, other.Member, och)
})
require.ErrorIs(t, err, payout.ErrNoProfile)
}
// TestConcurrentFirstSavesProduceOneRowAndHonestAudit: two first saves race;
// the loser must update the winner's row and audit it as an update with the
// winner's values as "before".
func TestConcurrentFirstSavesProduceOneRowAndHonestAudit(t *testing.T) {
e := payouttest.New(t)
ch := e.BankChannel(t)
ctx, cancel := context.WithTimeout(e.Ctx, 20*time.Second)
t.Cleanup(cancel)
saved, release := make(chan struct{}), make(chan struct{})
first := make(chan error, 1)
go func() {
first <- e.DB.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
if _, err := payout.SaveProfile(ctx, tx, e.Clock, e.Member, ch, good); err != nil {
return err
}
close(saved)
select { // hold the uncommitted insert until the second save is blocked on it
case <-release:
return nil
case <-ctx.Done():
return ctx.Err()
}
})
}()
select {
case <-saved:
case <-ctx.Done():
t.Fatal("first save never completed")
}
second := make(chan error, 1)
changed := maps.Clone(good)
changed["payee_name"] = "Aisyah Ali"
go func() {
second <- e.DB.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
_, err := payout.SaveProfile(ctx, tx, e.Clock, e.Member, ch, changed)
return err
})
}()
require.Eventually(t, func() bool { // the second is waiting on the first's row
var waiting int
_ = e.H.Admin.QueryRow(ctx, `SELECT count(*) FROM pg_stat_activity
WHERE wait_event_type = 'Lock' AND query LIKE '%member_payout_profile%'`).Scan(&waiting)
return waiting > 0
}, 10*time.Second, 20*time.Millisecond)
close(release)
for _, done := range []chan error{first, second} {
select {
case err := <-done:
require.NoError(t, err)
case <-ctx.Done():
t.Fatal("a save never returned")
}
}
var rows int
require.NoError(t, e.H.Admin.QueryRow(context.Background(),
`SELECT count(*) FROM member_payout_profile WHERE org_id = $1`, e.Org).Scan(&rows))
require.Equal(t, 1, rows)
var before string
require.NoError(t, e.H.Admin.QueryRow(context.Background(), `SELECT before::text FROM audit_log
WHERE org_id = $1 AND entity_kind = 'member_payout_profile' AND action = 'update'`, e.Org).Scan(&before))
require.Contains(t, before, "Aisyah binti Ali")
}
  • Step 2: Run and watch fail. go test ./internal/platform/payout/ -run Profile -count=1 → FAIL: undefined payout.SaveProfile.

  • Step 3: Implement profile.go

package payout
import (
"context"
"errors"
"fmt"
"sort"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/kernel/tenancy"
)
const kindProfile = "member_payout_profile"
// FieldView is one field of a saved profile as any API reader sees it.
// Value is nil for a masked field, always (Ruling P12).
type FieldView struct {
Key string `json:"key"`
Type string `json:"type"`
Label Names `json:"label"`
Sensitivity string `json:"sensitivity,omitempty"`
Value *string `json:"value"`
Display string `json:"display"`
}
type ProfileView struct {
ID uuid.UUID
ChannelID uuid.UUID
ChannelKey string
Fields []FieldView
Valid bool
Problems []FieldProblem
UpdatedAt time.Time
}
// auditView is what an audit row records of a profile (Ruling P13): display
// values only, and which keys changed.
type auditView struct {
MemberID uuid.UUID `json:"member_id"`
ChannelID uuid.UUID `json:"channel_id"`
ChannelKey string `json:"channel_key"`
Fields map[string]string `json:"fields"`
Changed []string `json:"changed,omitempty"`
// Unreadable marks a purge of a profile whose ciphertext would not open
// (Ruling P33): erased all the same, with no values recorded.
Unreadable bool `json:"unreadable,omitempty"`
}
func SaveProfile(ctx context.Context, tx pgx.Tx, c clock.Clock, memberID, channelID uuid.UUID,
details map[string]string) (ProfileView, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return ProfileView{}, fmt.Errorf("payout: save profile: %w", tenancy.ErrNoOrg)
}
var active bool
err := tx.QueryRow(ctx, `SELECT m.active FROM organization_member m WHERE m.id = $1 AND m.org_id = $2`,
memberID, orgID).Scan(&active)
if errors.Is(err, pgx.ErrNoRows) || (err == nil && !active) {
return ProfileView{}, ErrMemberNotFound
}
if err != nil {
return ProfileView{}, fmt.Errorf("payout: save profile: %w", err)
}
// Parent before child (wave-2 §3): the channel, FOR SHARE, so its schema
// cannot change under this validation.
d, err := scanDefinition(tx.QueryRow(ctx,
`SELECT `+channelColumns+` FROM payout_channel c WHERE c.id = $1 AND c.org_id = $2 FOR SHARE`, channelID, orgID))
if errors.Is(err, pgx.ErrNoRows) {
return ProfileView{}, ErrChannelNotFound
}
if err != nil {
return ProfileView{}, fmt.Errorf("payout: save profile: %w", err)
}
if !d.IsActive {
return ProfileView{}, ErrChannelInactive
}
values, err := ValidateDetails(ctx, tx, d.Fields, details)
if err != nil {
return ProfileView{}, err
}
// Sealed as one object under the active key (Ruling P15); the plaintext
// never reaches the database.
sealed, err := SealDetails(orgID, memberID, channelID, values)
if err != nil {
return ProfileView{}, fmt.Errorf("payout: save profile: %w", err)
}
// Insert-or-nothing first: a concurrent first save makes this wait for
// the other to commit, and then the row it made is read FOR UPDATE below
// as the honest "before".
var (
id uuid.UUID
updatedAt time.Time
prev map[string]string
action = "create"
)
err = tx.QueryRow(ctx, `
INSERT INTO member_payout_profile (org_id, member_id, channel_id, details_kid, details_nonce, details_ct)
VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT ON CONSTRAINT member_payout_profile_one_per_channel_uniq DO NOTHING
RETURNING id, updated_at`, orgID, memberID, channelID, sealed.KeyID, sealed.Nonce, sealed.Ciphertext).
Scan(&id, &updatedAt)
if errors.Is(err, pgx.ErrNoRows) {
action = "update"
var old Sealed
if err := tx.QueryRow(ctx, `SELECT p.id, p.details_kid, p.details_nonce, p.details_ct FROM member_payout_profile p
WHERE p.org_id = $1 AND p.member_id = $2 AND p.channel_id = $3 FOR UPDATE`,
orgID, memberID, channelID).Scan(&id, &old.KeyID, &old.Nonce, &old.Ciphertext); err != nil {
return ProfileView{}, fmt.Errorf("payout: save profile: %w", err)
}
// The previous values are opened only to write the masked "before".
if prev, err = openDetails(orgID, memberID, channelID, old); err != nil {
return ProfileView{}, fmt.Errorf("payout: save profile: %w", err)
}
if err := tx.QueryRow(ctx, `UPDATE member_payout_profile
SET details_kid = $1, details_nonce = $2, details_ct = $3, updated_at = now()
WHERE id = $4 AND org_id = $5 RETURNING updated_at`,
sealed.KeyID, sealed.Nonce, sealed.Ciphertext, id, orgID).Scan(&updatedAt); err != nil {
return ProfileView{}, fmt.Errorf("payout: save profile: %w", err)
}
} else if err != nil {
return ProfileView{}, fmt.Errorf("payout: save profile: %w", err)
}
after, err := auditOf(ctx, tx, d, memberID, values)
if err != nil {
return ProfileView{}, err
}
entry := audit.Entry{EntityKind: kindProfile, EntityID: id, Action: action}
if prev != nil {
before, err := auditOf(ctx, tx, d, memberID, prev)
if err != nil {
return ProfileView{}, err
}
after.Changed = changedKeys(prev, values)
entry.Before = before
} else {
after.Changed = changedKeys(nil, values)
}
entry.After = after
if err := audit.Write(ctx, tx, c, entry); err != nil {
return ProfileView{}, fmt.Errorf("payout: save profile: %w", err)
}
return view(ctx, tx, d, id, values, updatedAt)
}
func DeleteProfile(ctx context.Context, tx pgx.Tx, c clock.Clock, memberID, channelID uuid.UUID) error {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return fmt.Errorf("payout: delete profile: %w", tenancy.ErrNoOrg)
}
var (
id uuid.UUID
sealed Sealed
)
err := tx.QueryRow(ctx, `SELECT p.id, p.details_kid, p.details_nonce, p.details_ct FROM member_payout_profile p
WHERE p.org_id = $1 AND p.member_id = $2 AND p.channel_id = $3 FOR UPDATE`,
orgID, memberID, channelID).Scan(&id, &sealed.KeyID, &sealed.Nonce, &sealed.Ciphertext)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNoProfile
}
if err != nil {
return fmt.Errorf("payout: delete profile: %w", err)
}
d, err := GetDefinition(ctx, tx, channelID)
if err != nil {
return err
}
prev, err := openDetails(orgID, memberID, channelID, sealed)
if err != nil {
return fmt.Errorf("payout: delete profile: %w", err)
}
before, err := auditOf(ctx, tx, d, memberID, prev)
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `DELETE FROM member_payout_profile WHERE id = $1 AND org_id = $2`, id, orgID); err != nil {
return fmt.Errorf("payout: delete profile: %w", err)
}
return audit.Write(ctx, tx, c, audit.Entry{EntityKind: kindProfile, EntityID: id, Action: "delete", Before: before})
}
// ListProfiles returns the member's saved profiles, each checked against its
// channel's current schema without writing anything.
func ListProfiles(ctx context.Context, tx pgx.Tx, memberID uuid.UUID) ([]ProfileView, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return nil, fmt.Errorf("payout: list profiles: %w", tenancy.ErrNoOrg)
}
rows, err := tx.Query(ctx, `SELECT `+channelColumns+`, p.id, p.details_kid, p.details_nonce, p.details_ct, p.updated_at
FROM member_payout_profile p
JOIN payout_channel c ON c.org_id = p.org_id AND c.id = p.channel_id
WHERE p.org_id = $1 AND p.member_id = $2 ORDER BY c.sort_order, c.key`, orgID, memberID)
if err != nil {
return nil, fmt.Errorf("payout: list profiles: %w", err)
}
type row struct {
d Definition
id uuid.UUID
details map[string]string
updatedAt time.Time
}
var all []row
for rows.Next() {
var r row
var sealed Sealed
if r.d, err = scanDefinition(rows, &r.id, &sealed.KeyID, &sealed.Nonce, &sealed.Ciphertext, &r.updatedAt); err != nil {
rows.Close()
return nil, fmt.Errorf("payout: list profiles: %w", err)
}
// Opened in memory only, to build the masked view below.
if r.details, err = openDetails(orgID, memberID, r.d.ID, sealed); err != nil {
rows.Close()
return nil, fmt.Errorf("payout: list profiles: %w", err)
}
all = append(all, r)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("payout: list profiles: %w", err)
}
// The rows are drained before ValidateDetails issues its own queries on
// the same connection.
out := make([]ProfileView, 0, len(all))
for _, r := range all {
v, err := view(ctx, tx, r.d, r.id, r.details, r.updatedAt)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, nil
}
// view builds the masked read of stored values, validity included.
func view(ctx context.Context, tx pgx.Tx, d Definition, id uuid.UUID, values map[string]string, updatedAt time.Time) (ProfileView, error) {
v := ProfileView{ID: id, ChannelID: d.ID, ChannelKey: d.Key, Fields: []FieldView{}, Valid: true, UpdatedAt: updatedAt}
if _, err := ValidateDetails(ctx, tx, d.Fields, values); err != nil {
var invalid *InvalidDetailsError
if !errors.As(err, &invalid) {
return ProfileView{}, err
}
v.Valid, v.Problems = false, invalid.Problems
}
for _, f := range d.Fields {
val, ok := values[f.Key]
if !ok {
continue
}
disp, err := display(ctx, tx, f, val)
if err != nil {
return ProfileView{}, err
}
fv := FieldView{Key: f.Key, Type: f.Type, Label: f.Label, Sensitivity: f.Sensitivity, Display: disp}
if !f.Masked() {
s := val
fv.Value = &s
}
v.Fields = append(v.Fields, fv)
}
return v, nil
}
func auditOf(ctx context.Context, tx pgx.Tx, d Definition, memberID uuid.UUID, values map[string]string) (auditView, error) {
a := auditView{MemberID: memberID, ChannelID: d.ID, ChannelKey: d.Key, Fields: map[string]string{}}
byKey := map[string]Field{}
for _, f := range d.Fields {
byKey[f.Key] = f
}
for k, val := range values {
f, ok := byKey[k]
if !ok { // a key the current schema dropped: never write it raw
a.Fields[k] = Mask(val)
continue
}
disp, err := display(ctx, tx, f, val)
if err != nil {
return auditView{}, err
}
a.Fields[k] = disp
}
return a, nil
}
// ResealProfiles re-encrypts every profile of the calling organization
// sealed under a key other than the active one (Ruling P32) and returns how
// many it rewrote. Rows are locked in id order before the first audit.Write
// (wave-2 §3). Each reseal is audited with key ids only: the details do not
// change, and no value — masked or not — is written.
func ResealProfiles(ctx context.Context, tx pgx.Tx, c clock.Clock) (int, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return 0, fmt.Errorf("payout: reseal profiles: %w", tenancy.ErrNoOrg)
}
k, err := currentKeyring()
if err != nil {
return 0, err
}
rows, err := tx.Query(ctx, `SELECT p.id, p.member_id, p.channel_id, p.details_kid, p.details_nonce, p.details_ct
FROM member_payout_profile p WHERE p.org_id = $1 AND p.details_kid <> $2 ORDER BY p.id FOR UPDATE`,
orgID, k.ActiveKeyID())
if err != nil {
return 0, fmt.Errorf("payout: reseal profiles: %w", err)
}
type row struct {
id, member, channel uuid.UUID
sealed Sealed
}
var all []row
for rows.Next() {
var r row
if err := rows.Scan(&r.id, &r.member, &r.channel, &r.sealed.KeyID, &r.sealed.Nonce, &r.sealed.Ciphertext); err != nil {
rows.Close()
return 0, fmt.Errorf("payout: reseal profiles: %w", err)
}
all = append(all, r)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, fmt.Errorf("payout: reseal profiles: %w", err)
}
type keyIDs struct {
KeyID string `json:"key_id"`
}
for _, r := range all {
next, _, err := reseal(r.sealed, profileAAD(orgID, r.member, r.channel))
if err != nil {
return 0, fmt.Errorf("payout: reseal profile %s: %w", r.id, err)
}
if _, err := tx.Exec(ctx, `UPDATE member_payout_profile
SET details_kid = $1, details_nonce = $2, details_ct = $3
WHERE id = $4 AND org_id = $5`, next.KeyID, next.Nonce, next.Ciphertext, r.id, orgID); err != nil {
return 0, fmt.Errorf("payout: reseal profile %s: %w", r.id, err)
}
if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: kindProfile, EntityID: r.id, Action: "reseal",
Before: keyIDs{KeyID: r.sealed.KeyID}, After: keyIDs{KeyID: next.KeyID}}); err != nil {
return 0, fmt.Errorf("payout: reseal profile %s: %w", r.id, err)
}
}
return len(all), nil
}
func changedKeys(before, after map[string]string) []string {
var out []string
for k, v := range after {
if before[k] != v {
out = append(out, k)
}
}
for k := range before {
if _, ok := after[k]; !ok {
out = append(out, k)
}
}
sort.Strings(out)
return out
}
  • Step 4: Run. go test ./internal/platform/payout/ -count=1 -race → PASS. Sabotage for P13: make auditOf write val instead of disp → TestAuditNeverHoldsARawSensitiveValue red with audit row create holds a raw account number; restore. Sabotage for P15: in SaveProfile, after sealing, set sealed.Ciphertext, _ = json.Marshal(values) (the plaintext JSON stored as the “ciphertext”) → TestStoredDetailsAreCiphertext red with ciphertext holds "514012345678"; restore. Sabotage for P32: drop AND p.details_kid <> $2 from ResealProfiles → TestProfilesSurviveKeyRotationAndReseal red (2 resealed, want 1); restore. Run go test ./internal/kernel/layering/ -count=1 → PASS.

  • Step 5: Commit

Terminal window
git add internal/platform/payout/profile.go internal/platform/payout/profile_test.go
git commit -m "Member payout profiles: validated, sealed at rest, masked on every read and audit; reseal for key rotation"

Task 5: Malaysia seeds and provisioning steps 90–91

Section titled “Task 5: Malaysia seeds and provisioning steps 90–91”

Unverified data (Ruling P34, user decision). The Malaysian bank, e-wallet and crypto-network lists in countrypack/my/payout.go are unverified — confirm before any paying customer. Keep the comment at each list’s definition exactly as written below; do not “improve” the lists from memory in this task.

Files:

  • Create: internal/platform/payout/seed.go, internal/platform/payout/steps.go
  • Create: internal/platform/countrypack/my/payout.go, internal/platform/countrypack/my/payout_test.go
  • Test: internal/platform/payout/seed_test.go, internal/platform/payout/steps_test.go
  • Modify: provisioning-key expectations in tests that enumerate steps (see Step 6)

Interfaces:

  • Consumes: CreateChannel, CheckDefinition, ListChannels (Task 2); CreateRegistryEntry (Task 3); ValidateSchema, validateNames, keyPattern (Task 1); Plan 4: chart.DefaultJournal, company.Get, provisioning.Register, provisioning.Step, provisioning.Warning.

  • Produces:

    type RegistrySeed struct { Registry, Key string; Names Names }
    type ChannelSeed struct { Key string; Names Names; SettlementBehaviour string
    CreditJournalType string /* chart.JournalBank | chart.JournalCash */; Fields []Field
    RequiresSettlementProof bool; SortOrder int; Inactive bool }
    type CountrySeeds struct { Registries []RegistrySeed; Channels []ChannelSeed }
    type NoSeedsError struct{ Country string }
    func RegisterCountrySeeds(country string, s CountrySeeds) // panics on invalid or duplicate
    func SeedsFor(country string) (CountrySeeds, bool)
    func ValidateSeeds(s CountrySeeds) error
    func SeedRegistries(ctx context.Context, tx pgx.Tx, c clock.Clock, s CountrySeeds) error
    func SeedChannels(ctx context.Context, tx pgx.Tx, c clock.Clock, s CountrySeeds) error
    const (StepRegistries = "payout_registries"; StepChannels = "payout_channels"
    OrderRegistries = 90; OrderChannels = 91; WarnChannelInvalid = "payout_channel_invalid")
  • Step 1: Write the failing tests

countrypack/my/payout_test.go (package my_test):

func TestMalaysiaSeedsTheSevenChannelsOfSpec95(t *testing.T) {
s, ok := payout.SeedsFor("MY")
require.True(t, ok, "countrypack/my did not register payout seeds")
require.NoError(t, payout.ValidateSeeds(s))
got := map[string]payout.ChannelSeed{}
for _, ch := range s.Channels {
got[ch.Key] = ch
}
require.ElementsMatch(t, []string{"cash", "bank_transfer", "duitnow", "ewallet", "cheque", "crypto", "petty_cash"},
slices.Collect(maps.Keys(got)))
fieldKeys := func(k string) []string {
var out []string
for _, f := range got[k].Fields {
out = append(out, f.Key)
}
return out
}
require.Equal(t, []string{"payee_name"}, fieldKeys("cash"))
require.Equal(t, []string{"payee_name", "bank", "account_no"}, fieldKeys("bank_transfer"))
require.Equal(t, []string{"payee_name", "duitnow_id"}, fieldKeys("duitnow"), "DuitNow has no bank field")
require.Equal(t, []string{"payee_name", "ewallet_provider", "wallet_id"}, fieldKeys("ewallet"))
require.Equal(t, []string{"payee_name", "cheque_no"}, fieldKeys("cheque"))
require.Equal(t, []string{"crypto_network", "crypto_address"}, fieldKeys("crypto"))
require.Equal(t, []string{"payee_name"}, fieldKeys("petty_cash"))
for k, ch := range got {
want := payout.BehaviourExternal
if k == "petty_cash" {
want = payout.BehaviourPettyCash
}
require.Equal(t, want, ch.SettlementBehaviour, k)
}
require.True(t, got["crypto"].Inactive, "crypto is seeded inactive (Ruling P23)")
for _, k := range []string{"duitnow", "ewallet", "crypto"} {
for _, f := range got[k].Fields[1:] {
if f.Type != payout.FieldRegistryRef {
require.True(t, f.Masked(), "%s.%s must be masked", k, f.Key)
}
}
}
}

seed_test.go:

func TestValidateSeedsRefuses(t *testing.T) {
good := payout.CountrySeeds{
Registries: []payout.RegistrySeed{{Registry: "my_banks", Key: "maybank", Names: payout.Names{"en": "Maybank", "ms": "Maybank"}}},
Channels: []payout.ChannelSeed{{Key: "bank_transfer", Names: payout.Names{"en": "Bank", "ms": "Bank"},
SettlementBehaviour: payout.BehaviourExternal, CreditJournalType: "bank", Fields: payouttest.BankFields()}},
}
require.NoError(t, payout.ValidateSeeds(good)) // PRESENCE
for name, mutate := range map[string]func(s *payout.CountrySeeds){
"unknown registry": func(s *payout.CountrySeeds) { s.Registries = nil },
"duplicate channel": func(s *payout.CountrySeeds) { s.Channels = append(s.Channels, s.Channels[0]) },
"misc journal": func(s *payout.CountrySeeds) { s.Channels[0].CreditJournalType = "misc" },
"petty cash via bank": func(s *payout.CountrySeeds) {
s.Channels[0].SettlementBehaviour = payout.BehaviourPettyCash
s.Channels[0].Fields = payouttest.BankFields()[:1]
},
"petty cash with bank fields": func(s *payout.CountrySeeds) {
s.Channels[0].SettlementBehaviour = payout.BehaviourPettyCash
s.Channels[0].CreditJournalType = "cash"
},
} {
t.Run(name, func(t *testing.T) {
s := good
s.Registries = slices.Clone(good.Registries)
s.Channels = slices.Clone(good.Channels)
mutate(&s)
require.Error(t, payout.ValidateSeeds(s))
})
}
}

steps_test.go:

// This binary links no module, so its permission registry is empty and the
// roles step would refuse it (onboarding_test.go's reason for the same var).
var testPerms = member.NewPermissionSet([]member.Permission{{Key: "test.payout.steps", Description: "test"}})
func TestStepsSeedAMalaysianOrgOnce(t *testing.T) {
acc := accountingtest.New(t) // company profile MY, MYR, locale en — no chart yet
owner := acc.SeedMember(t, "Owner")
run := func() provisioning.Report {
var r provisioning.Report
acc.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
r, err = provisioning.Run(ctx, tx, acc.Clock, provisioning.Input{
OwnerPermissions: testPerms, OwnerMemberID: owner})
return err
})
return r
}
run()
acc.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
chs, err := payout.ListChannels(ctx, tx, false)
require.NoError(t, err)
require.Len(t, chs, 7)
bank, err := chart.DefaultJournal(ctx, tx, chart.JournalBank)
require.NoError(t, err)
cash, err := chart.DefaultJournal(ctx, tx, chart.JournalCash)
require.NoError(t, err)
for _, d := range chs {
require.Equal(t, []string{"MYR"}, d.AllowedCurrencies, d.Key)
want := *bank.DefaultAccountID
if d.Key == "cash" || d.Key == "petty_cash" {
want = *cash.DefaultAccountID
}
require.Equal(t, want, d.CreditAccountID, d.Key)
require.Equal(t, d.Key != "crypto", d.IsActive, d.Key)
problems, err := payout.CheckDefinition(ctx, tx, d)
require.NoError(t, err)
require.Empty(t, problems, d.Key)
}
banks, err := payout.ListRegistryEntries(ctx, tx, "my_banks", true)
require.NotEmpty(t, banks)
return err
})
// Idempotent: a second run seeds nothing more (spec 20.2).
run()
var n int
require.NoError(t, acc.H.Admin.QueryRow(context.Background(),
`SELECT count(*) FROM payout_channel WHERE org_id = $1`, acc.Org).Scan(&n))
require.Equal(t, 7, n)
}
func TestChannelsStepWarnsAboutAnInvalidActiveChannel(t *testing.T) {
acc := accountingtest.New(t)
owner := acc.SeedMember(t, "Owner")
acc.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, err := provisioning.Run(ctx, tx, acc.Clock, provisioning.Input{
OwnerPermissions: testPerms, OwnerMemberID: owner})
return err
})
readiness := func() []provisioning.Warning {
var r provisioning.Report
acc.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
r, err = provisioning.Readiness(ctx, tx, acc.Clock)
return err
})
for _, s := range r.Steps {
if s.Key == payout.StepChannels {
return s.Warnings
}
}
t.Fatal("no payout_channels step in the readiness report")
return nil
}
require.Empty(t, readiness()) // PRESENCE of a clean report first
_, err := acc.H.Admin.Exec(context.Background(),
`UPDATE journal SET is_active = false WHERE org_id = $1 AND type = 'bank'`, acc.Org)
require.NoError(t, err)
ws := readiness()
require.NotEmpty(t, ws)
require.Equal(t, payout.WarnChannelInvalid, ws[0].Code)
}
func TestStepsRegisterAt90And91(t *testing.T) {
orders := map[string]int{}
for _, s := range provisioning.Steps() {
orders[s.Key()] = s.Order()
}
require.Equal(t, 90, orders[payout.StepRegistries])
require.Equal(t, 91, orders[payout.StepChannels])
}

The steps test needs the Malaysia pack and payout seeds linked: add to steps_test.go the blank import _ "github.com/mcom/erp/internal/platform/countrypack/my". (provisioning.Report{Steps []StepState} and StepState{Key, Order, Done, Error, CompletedAt, Warnings} are the real shapes, read from provisioning.go.)

  • Step 2: Run and watch fail. go test ./internal/platform/payout/ ./internal/platform/countrypack/my/ -count=1 → FAIL: undefined payout.SeedsFor.

  • Step 3: Implement seed.go

package payout
import (
"context"
"errors"
"fmt"
"sync"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/platform/accounting/chart"
"github.com/mcom/erp/internal/platform/company"
)
type RegistrySeed struct {
Registry, Key string
Names Names
}
// ChannelSeed names the journal TYPE whose default account the channel
// credits — never an account code (spec 10.1: a module never hardcodes one).
type ChannelSeed struct {
Key string
Names Names
SettlementBehaviour string
CreditJournalType string
Fields []Field
RequiresSettlementProof bool
SortOrder int
Inactive bool
}
type CountrySeeds struct {
Registries []RegistrySeed
Channels []ChannelSeed
}
type NoSeedsError struct{ Country string }
func (e *NoSeedsError) Error() string {
return fmt.Sprintf("payout: no payout seeds are registered for country %q", e.Country)
}
var (
seedsMu sync.RWMutex
seeds = map[string]CountrySeeds{}
)
// RegisterCountrySeeds is called from a country pack's init() (Ruling P22).
// Invalid or duplicate seeds panic: a broken pack fails at boot and in CI,
// never in a customer's provisioning run (countrypack.Register's rule).
func RegisterCountrySeeds(country string, s CountrySeeds) {
if err := ValidateSeeds(s); err != nil {
panic(fmt.Errorf("payout: register seeds %q: %w", country, err))
}
seedsMu.Lock()
defer seedsMu.Unlock()
if _, dup := seeds[country]; dup {
panic(fmt.Errorf("payout: seeds for %q are already registered", country))
}
seeds[country] = s
}
func SeedsFor(country string) (CountrySeeds, bool) {
seedsMu.RLock()
defer seedsMu.RUnlock()
s, ok := seeds[country]
return s, ok
}
// ValidateSeeds checks what can be checked without a database.
func ValidateSeeds(s CountrySeeds) error {
var errs []error
registries := map[string]bool{}
entries := map[string]bool{}
for _, r := range s.Registries {
id := r.Registry + "/" + r.Key
if !keyPattern.MatchString(r.Registry) || !optionPattern.MatchString(r.Key) || entries[id] {
errs = append(errs, fmt.Errorf("registry entry %s: bad or duplicate key", id))
}
if msg := validateNames(r.Names); msg != "" {
errs = append(errs, fmt.Errorf("registry entry %s: names %s", id, msg))
}
entries[id], registries[r.Registry] = true, true
}
channels := map[string]bool{}
for _, ch := range s.Channels {
if !keyPattern.MatchString(ch.Key) || channels[ch.Key] {
errs = append(errs, fmt.Errorf("channel %q: bad or duplicate key", ch.Key))
}
channels[ch.Key] = true
if msg := validateNames(ch.Names); msg != "" {
errs = append(errs, fmt.Errorf("channel %q: names %s", ch.Key, msg))
}
if ch.SettlementBehaviour != BehaviourExternal && ch.SettlementBehaviour != BehaviourPettyCash {
errs = append(errs, fmt.Errorf("channel %q: unknown settlement behaviour", ch.Key))
}
if ch.CreditJournalType != chart.JournalBank && ch.CreditJournalType != chart.JournalCash {
errs = append(errs, fmt.Errorf("channel %q: credits through a bank or cash journal only", ch.Key))
}
if err := ValidateSchema(ch.Fields); err != nil {
errs = append(errs, fmt.Errorf("channel %q: %w", ch.Key, err))
}
for _, f := range ch.Fields {
if f.Type == FieldRegistryRef && !registries[f.Registry] {
errs = append(errs, fmt.Errorf("channel %q: field %s names unseeded registry %s", ch.Key, f.Key, f.Registry))
}
if ch.SettlementBehaviour == BehaviourPettyCash && (f.Type == FieldAccountNo || f.Type == FieldRegistryRef) {
errs = append(errs, fmt.Errorf("channel %q: petty_cash cannot ask for bank details", ch.Key))
}
}
if ch.SettlementBehaviour == BehaviourPettyCash && ch.CreditJournalType != chart.JournalCash {
errs = append(errs, fmt.Errorf("channel %q: petty_cash credits through a cash journal", ch.Key))
}
}
return errors.Join(errs...)
}
func seedsForOrg(ctx context.Context, tx pgx.Tx) (CountrySeeds, company.Profile, error) {
profile, err := company.Get(ctx, tx)
if err != nil {
return CountrySeeds{}, company.Profile{}, err
}
s, ok := SeedsFor(profile.CountryCode)
if !ok {
return CountrySeeds{}, company.Profile{}, &NoSeedsError{Country: profile.CountryCode}
}
return s, profile, nil
}
// SeedRegistries creates every seeded entry through CreateRegistryEntry, so
// a seeded row is audited and validated like a hand-made one.
func SeedRegistries(ctx context.Context, tx pgx.Tx, c clock.Clock, s CountrySeeds) error {
for i, r := range s.Registries {
if _, err := CreateRegistryEntry(ctx, tx, c, RegistryEntryInput{
Registry: r.Registry, Key: r.Key, Names: r.Names, SortOrder: (i + 1) * 10}); err != nil {
return fmt.Errorf("payout: seed registry entry %s/%s: %w", r.Registry, r.Key, err)
}
}
return nil
}
// SeedChannels binds each channel to the default journal of its type and
// allows the base currency only (Ruling P27).
func SeedChannels(ctx context.Context, tx pgx.Tx, c clock.Clock, s CountrySeeds) error {
profile, err := company.Get(ctx, tx)
if err != nil {
return fmt.Errorf("payout: seed channels: %w", err)
}
for _, ch := range s.Channels {
j, err := chart.DefaultJournal(ctx, tx, ch.CreditJournalType)
if err != nil {
return fmt.Errorf("payout: seed channel %q: %w", ch.Key, err)
}
if _, err := CreateChannel(ctx, tx, c, ChannelInput{Key: ch.Key, Names: ch.Names,
SettlementBehaviour: ch.SettlementBehaviour, CreditAccountID: *j.DefaultAccountID, Fields: ch.Fields,
AllowedCurrencies: []string{profile.BaseCurrency}, RequiresSettlementProof: ch.RequiresSettlementProof,
SortOrder: ch.SortOrder, Inactive: ch.Inactive}); err != nil {
return fmt.Errorf("payout: seed channel %q: %w", ch.Key, err)
}
}
return nil
}
  • Step 4: Implement steps.go
package payout
import (
"context"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/kernel/tenancy"
"github.com/mcom/erp/internal/platform/provisioning"
)
// Steps 90 and 91 of this plan's 90–99 slice (wave-2 contract §2); spec
// 20.2 step 6 ("registries: payout channels"). Both run after the chart (70)
// and journals (71) they bind to.
const (
StepRegistries = "payout_registries"
StepChannels = "payout_channels"
OrderRegistries = 90
OrderChannels = 91
// WarnChannelInvalid: an active channel breaks a spec 9.7 rule — its
// account was retired, its journal deactivated, its registry emptied.
WarnChannelInvalid = "payout_channel_invalid"
)
func init() {
provisioning.Register(registriesStep{})
provisioning.Register(channelsStep{})
}
type registriesStep struct{}
func (registriesStep) Key() string { return StepRegistries }
func (registriesStep) Order() int { return OrderRegistries }
// Check is done when the org has any registry entry: never seed over what an
// administrator has shaped (Plan 4 R29's rule).
func (registriesStep) Check(ctx context.Context, tx pgx.Tx, _ clock.Clock) (bool, []provisioning.Warning, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return false, nil, fmt.Errorf("payout: registries check: %w", tenancy.ErrNoOrg)
}
var exists bool
if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM payout_registry_entry r WHERE r.org_id = $1)`,
orgID).Scan(&exists); err != nil {
return false, nil, fmt.Errorf("payout: registries check: %w", err)
}
return exists, nil, nil
}
func (registriesStep) Run(ctx context.Context, tx pgx.Tx, c clock.Clock, _ provisioning.Input) error {
s, _, err := seedsForOrg(ctx, tx)
if err != nil {
return fmt.Errorf("payout: registries run: %w", err)
}
return SeedRegistries(ctx, tx, c, s)
}
type channelsStep struct{}
func (channelsStep) Key() string { return StepChannels }
func (channelsStep) Order() int { return OrderChannels }
func (channelsStep) Check(ctx context.Context, tx pgx.Tx, _ clock.Clock) (bool, []provisioning.Warning, error) {
chs, err := ListChannels(ctx, tx, false)
if err != nil {
return false, nil, fmt.Errorf("payout: channels check: %w", err)
}
var ws []provisioning.Warning
for _, d := range chs {
if !d.IsActive {
continue
}
problems, err := CheckDefinition(ctx, tx, d)
if err != nil {
return false, nil, fmt.Errorf("payout: channels check: %w", err)
}
if len(problems) > 0 {
ws = append(ws, provisioning.Warning{Code: WarnChannelInvalid, Ref: d.ID,
Detail: fmt.Sprintf("Payout channel %q: %s.", d.Key, strings.Join(problems, "; "))})
}
}
return len(chs) > 0, ws, nil
}
func (channelsStep) Run(ctx context.Context, tx pgx.Tx, c clock.Clock, _ provisioning.Input) error {
s, _, err := seedsForOrg(ctx, tx)
if err != nil {
return fmt.Errorf("payout: channels run: %w", err)
}
return SeedChannels(ctx, tx, c, s)
}
  • Step 5: Implement countrypack/my/payout.go
package my
import "github.com/mcom/erp/internal/platform/payout"
// Malaysia's payout seeds (spec 9.5): korre's seven disbursement methods as
// seven channels, and the registries their registry_ref fields point at.
//
// The bank, e-wallet and crypto-network lists below are UNVERIFIED —
// CONFIRM BEFORE ANY PAYING CUSTOMER (Ruling P34; the same treatment as
// Plan 4's SST rates). Each is transcribed, not checked against Bank
// Negara's list of licensed institutions and approved e-money issuers or the
// Securities Commission's registered digital-asset exchanges. An org can add
// or retire entries itself.
func init() {
payout.RegisterCountrySeeds("MY", payout.CountrySeeds{Registries: payoutRegistries, Channels: payoutChannels})
}
func pn(en, ms, zh string) payout.Names { return payout.Names{"en": en, "ms": ms, "zh": zh} }
func entries(registry string, kv ...string) []payout.RegistrySeed {
var out []payout.RegistrySeed
for i := 0; i < len(kv); i += 2 {
out = append(out, payout.RegistrySeed{Registry: registry, Key: kv[i], Names: pn(kv[i+1], kv[i+1], kv[i+1])})
}
return out
}
var payoutRegistries = append(append(
// my_banks: UNVERIFIED — confirm before any paying customer (Ruling P34)
// against Bank Negara Malaysia's list of licensed banks and Islamic banks.
entries("my_banks",
"maybank", "Maybank", "cimb", "CIMB Bank", "public_bank", "Public Bank", "rhb", "RHB Bank",
"hong_leong", "Hong Leong Bank", "ambank", "AmBank", "uob", "UOB Malaysia", "ocbc", "OCBC Bank Malaysia",
"hsbc", "HSBC Bank Malaysia", "standard_chartered", "Standard Chartered Malaysia", "affin", "Affin Bank",
"alliance", "Alliance Bank", "bank_islam", "Bank Islam", "bank_rakyat", "Bank Rakyat",
"bank_muamalat", "Bank Muamalat", "bsn", "Bank Simpanan Nasional", "agrobank", "Agrobank",
"mbsb", "MBSB Bank", "al_rajhi", "Al Rajhi Bank Malaysia", "kfh", "Kuwait Finance House Malaysia",
"citibank", "Citibank Malaysia"),
// my_ewallets: UNVERIFIED — confirm before any paying customer (Ruling
// P34) against Bank Negara Malaysia's list of approved e-money issuers.
entries("my_ewallets",
"tng_ewallet", "Touch 'n Go eWallet", "grabpay", "GrabPay", "boost", "Boost",
"shopeepay", "ShopeePay", "bigpay", "BigPay", "mae", "MAE by Maybank")...),
// crypto_networks: UNVERIFIED — confirm before any paying customer
// (Ruling P34); the crypto channel is seeded inactive (Ruling P23).
entries("crypto_networks",
"usdt_trc20", "USDT (TRC-20)", "usdt_erc20", "USDT (ERC-20)", "usdc_erc20", "USDC (ERC-20)",
"btc", "Bitcoin (BTC)", "eth", "Ether (ETH)")...)
var payeeName = payout.Field{Key: "payee_name", Type: payout.FieldText, Required: true, Max: 140,
Label: pn("Payee name", "Nama penerima", "收款人姓名")}
var payoutChannels = []payout.ChannelSeed{
{Key: "cash", SortOrder: 10, SettlementBehaviour: payout.BehaviourExternal, CreditJournalType: "cash",
Names: pn("Cash", "Tunai", "现金"), Fields: []payout.Field{payeeName}},
{Key: "bank_transfer", SortOrder: 20, SettlementBehaviour: payout.BehaviourExternal, CreditJournalType: "bank",
RequiresSettlementProof: true, Names: pn("Bank transfer", "Pindahan bank", "银行转账"),
Fields: []payout.Field{payeeName,
{Key: "bank", Type: payout.FieldRegistryRef, Registry: "my_banks", Required: true, Label: pn("Bank", "Bank", "银行")},
{Key: "account_no", Type: payout.FieldAccountNo, Required: true, Sensitivity: payout.SensitivityMasked,
Label: pn("Account number", "Nombor akaun", "银行账号")}}},
{Key: "duitnow", SortOrder: 30, SettlementBehaviour: payout.BehaviourExternal, CreditJournalType: "bank",
RequiresSettlementProof: true, Names: pn("DuitNow", "DuitNow", "DuitNow"),
Fields: []payout.Field{payeeName,
{Key: "duitnow_id", Type: payout.FieldText, Required: true, Max: 40, Sensitivity: payout.SensitivityMasked,
Label: pn("DuitNow ID", "ID DuitNow", "DuitNow 号码"),
Help: pn("Mobile, NRIC, passport, army/police no. or business registration no.",
"Nombor telefon bimbit, MyKad, pasport, nombor tentera/polis atau nombor pendaftaran perniagaan",
"手机号码、身份证、护照、军警号码或商业注册号码")}}},
{Key: "ewallet", SortOrder: 40, SettlementBehaviour: payout.BehaviourExternal, CreditJournalType: "bank",
RequiresSettlementProof: true, Names: pn("E-wallet", "E-dompet", "电子钱包"),
Fields: []payout.Field{payeeName,
{Key: "ewallet_provider", Type: payout.FieldRegistryRef, Registry: "my_ewallets", Required: true,
Label: pn("E-wallet provider", "Penyedia e-dompet", "电子钱包提供商")},
{Key: "wallet_id", Type: payout.FieldText, Required: true, Max: 40, Sensitivity: payout.SensitivityMasked,
Label: pn("Wallet ID", "ID e-dompet", "钱包账号")}}},
{Key: "cheque", SortOrder: 50, SettlementBehaviour: payout.BehaviourExternal, CreditJournalType: "bank",
RequiresSettlementProof: true, Names: pn("Cheque", "Cek", "支票"),
Fields: []payout.Field{payeeName,
{Key: "cheque_no", Type: payout.FieldText, Max: 20, Label: pn("Cheque number", "Nombor cek", "支票号码")}}},
{Key: "crypto", SortOrder: 60, SettlementBehaviour: payout.BehaviourExternal, CreditJournalType: "bank",
RequiresSettlementProof: true, Inactive: true, Names: pn("Crypto", "Kripto", "加密货币"),
Fields: []payout.Field{
{Key: "crypto_network", Type: payout.FieldRegistryRef, Registry: "crypto_networks", Required: true,
Label: pn("Network and token", "Rangkaian dan token", "网络与代币")},
{Key: "crypto_address", Type: payout.FieldText, Required: true, Max: 128, Sensitivity: payout.SensitivityMasked,
Label: pn("Wallet address", "Alamat dompet", "钱包地址")}}},
{Key: "petty_cash", SortOrder: 70, SettlementBehaviour: payout.BehaviourPettyCash, CreditJournalType: "cash",
Names: pn("Petty cash", "Tunai runcit", "零用金"), Fields: []payout.Field{payeeName}},
}
  • Step 6: Run the new tests, then every provisioning consumer

Run: go test ./internal/platform/payout/ ./internal/platform/countrypack/... -count=1 → PASS.

Linking countrypack/my now links payout, so steps 90–91 join every binary and test that links the Malaysia pack (including modules/accounting’s). Run: go test ./internal/platform/onboarding/ ./internal/platform/provisioning/ ./internal/modules/identity/ ./internal/modules/accounting/ ./cmd/orgadmin/ ./cmd/migrate/ -count=1. A test that enumerates step keys must now list payout_registries and payout_channels: update those expectations — they are the tests telling the truth. A failure in a payout step’s Run for an MY org is a defect in this task: fix it here, never by loosening the test.

  • Step 7: Commit
Terminal window
git add internal/platform/payout/seed.go internal/platform/payout/steps.go internal/platform/payout/seed_test.go \
internal/platform/payout/steps_test.go internal/platform/countrypack/my/payout.go \
internal/platform/countrypack/my/payout_test.go
git add -u # the step-key expectations updated in Step 6
git commit -m "Seed Malaysia's seven payout channels at provisioning steps 90-91"

Task 6: modules/payout — composition, the permission, administrator routes, the payout key

Section titled “Task 6: modules/payout — composition, the permission, administrator routes, the payout key”

Files:

  • Create: internal/modules/payout/{permissions,routes,boot,http,errors,channel_handlers,registry_handlers}.go
  • Modify: constants.yaml (append payout.channel.manage) → make generate
  • Modify: api/openapi.yaml (append paths and schemas) → make generate
  • Modify: internal/app/app.go, internal/app/routes_test.go (composedModules), cmd/api/main.go, cmd/migrate/main.go, cmd/orgadmin/main.go, cmd/migrate/wiring_test.go, cmd/orgadmin/wiring_test.go
  • Modify (user-directed, append-only; Rulings P30, P32): internal/kernel/config/config.go, .env.example, docs/DEPLOY_NOTES.md
  • Test: internal/kernel/config/payoutkeys_test.go
  • Test: internal/modules/payout/{routes_test,channel_handlers_test,registry_handlers_test,vocab_test}.go

Interfaces:

  • Consumes: Tasks 1–5 (platformpayout.*, including Key, NewKeyring, InstallKeyring; payouttest.NewKey, UseKeyring for the harness); httpx.Protected, member.RegisterPermission, member.Permissions, i18n.Load.
  • Produces:
    package payout // internal/modules/payout; imports the platform as platformpayout
    const PermissionChannelManage = "payout.channel.manage"
    type Deps struct { DB *db.DB; Clock clock.Clock }
    func Register(g *gin.RouterGroup, deps Deps)
    // http.go: decodeStrict, optional[T], parseID, labels, label, blank — copies of
    // internal/modules/accounting/http.go's (a module never imports another module).
    func mapErr(err error) error
    package config // internal/kernel/config
    type KeyedSecret struct { ID string; Secret []byte }
    type PayoutDetailsKeys struct { Active KeyedSecret; Previous *KeyedSecret }
    func PayoutDetailsKeysFromEnv() (PayoutDetailsKeys, error)

permissions.go:

package payout
import "github.com/mcom/erp/internal/platform/identity/member"
// PermissionChannelManage gates channel and registry administration (Ruling
// P21). A member's own payout details need no permission: the /payout/me
// routes act only on the caller's member id.
const PermissionChannelManage = "payout.channel.manage"
func init() {
member.RegisterPermission(PermissionChannelManage,
"Create and edit payout channels, their GL binding and field schema, and the payout registries")
}

boot.go:

package payout
import (
// Provisioning steps 90–91 register from the platform package's init();
// the Malaysia seeds from countrypack/my's. cmd/api, cmd/migrate and
// cmd/orgadmin link this module (internal/app/binaries_test.go).
_ "github.com/mcom/erp/internal/platform/countrypack/my"
_ "github.com/mcom/erp/internal/platform/payout"
)

routes.go (the permission loader is the same few lines as internal/modules/accounting/routes.go’s permissionLoader, copied with payout: in its error prefix):

// Package payout is the HTTP surface of payout channels (spec 9): channel
// and registry administration behind payout.channel.manage, and — from Task 7
// — a member's own payout details under /payout/me. A thin shell over
// internal/platform/payout, which owns every rule and writes every audit row.
package payout
func Register(g *gin.RouterGroup, deps Deps) {
h := handlers{deps: deps}
p := permissionLoader{db: deps.DB}
m := PermissionChannelManage
httpx.Protected(g, p, "GET", "/payout/channels", m, h.listChannels)
httpx.Protected(g, p, "POST", "/payout/channels", m, h.createChannel)
httpx.Protected(g, p, "PATCH", "/payout/channels/:id", m, h.updateChannel)
httpx.Protected(g, p, "GET", "/payout/registry-entries", m, h.listRegistryEntries)
httpx.Protected(g, p, "POST", "/payout/registry-entries", m, h.createRegistryEntry)
httpx.Protected(g, p, "PATCH", "/payout/registry-entries/:id", m, h.updateRegistryEntry)
}
type Deps struct {
DB *db.DB
Clock clock.Clock
}
type handlers struct{ deps Deps }

errors.go:

package payout
// mapErr translates the platform's refusals. A 409 means the same request
// would succeed against records in another state; a 422 means it is wrong
// whatever the data holds (accounting's split).
//
// ErrChannelNotFound, ErrRegistryEntryNotFound, ErrNoProfile 404
// ErrDuplicateChannelKey, ErrDuplicateRegistryKey 409
// ErrRegistryInUse, ErrChannelInactive 409
// ErrMemberNotFound 403 the caller's membership is not active
// *InvalidChannelError, *InvalidSchemaError, *InvalidDetailsError 422, every problem in detail
func mapErr(err error) error {
var (
invCh *platformpayout.InvalidChannelError
invSchema *platformpayout.InvalidSchemaError
invDetails *platformpayout.InvalidDetailsError
)
switch {
case errors.Is(err, platformpayout.ErrChannelNotFound),
errors.Is(err, platformpayout.ErrRegistryEntryNotFound),
errors.Is(err, platformpayout.ErrNoProfile):
return apperr.New(apperr.KindNotFound, "no such record in this organization")
case errors.Is(err, platformpayout.ErrDuplicateChannelKey):
return apperr.New(apperr.KindConflict, "a payout channel with that key already exists")
case errors.Is(err, platformpayout.ErrDuplicateRegistryKey):
return apperr.New(apperr.KindConflict, "that registry already has an entry with that key")
case errors.Is(err, platformpayout.ErrRegistryInUse):
return apperr.New(apperr.KindConflict,
"an active channel uses this registry and this is its last active entry; add another or retire the channel first")
case errors.Is(err, platformpayout.ErrChannelInactive):
return apperr.New(apperr.KindConflict, "the payout channel is retired")
case errors.Is(err, platformpayout.ErrMemberNotFound):
return apperr.New(apperr.KindForbidden, "your membership in this organization is not active")
case errors.As(err, &invCh):
return apperr.New(apperr.KindValidation, strings.Join(invCh.Problems, "; "))
case errors.As(err, &invSchema):
return apperr.New(apperr.KindValidation, strings.Join(invSchema.Problems, "; "))
case errors.As(err, &invDetails):
parts := make([]string, len(invDetails.Problems))
for i, p := range invDetails.Problems {
parts[i] = p.Key + ": " + p.Reason
}
return apperr.New(apperr.KindValidation, strings.Join(parts, "; "))
}
return err
}

channel_handlers.go:

package payout
const (
kindChannel = "payout_channel" // translation entity kinds (Ruling P8)
kindRegistryEntry = "payout_registry_entry"
)
type channelDTO struct {
ID uuid.UUID `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Label string `json:"label"`
SettlementBehaviour string `json:"settlement_behaviour"`
CreditAccountID uuid.UUID `json:"credit_account_id"`
FieldSchema []platformpayout.Field `json:"field_schema"`
AllowedCurrencies []string `json:"allowed_currencies"`
AllowedClaimTypes []string `json:"allowed_claim_types"`
MaxAmount *money.Amount `json:"max_amount"`
RequiresSettlementProof bool `json:"requires_settlement_proof"`
SortOrder int `json:"sort_order"`
Active bool `json:"active"`
}
func toChannelDTO(d platformpayout.Definition, l map[uuid.UUID]string) channelDTO {
return channelDTO{ID: d.ID, Key: d.Key, Name: d.Name, Label: label(l, d.ID, d.Name),
SettlementBehaviour: d.SettlementBehaviour, CreditAccountID: d.CreditAccountID, FieldSchema: d.Fields,
AllowedCurrencies: d.AllowedCurrencies, AllowedClaimTypes: d.AllowedClaimTypes, MaxAmount: d.MaxAmount,
RequiresSettlementProof: d.RequiresSettlementProof, SortOrder: d.SortOrder, Active: d.IsActive}
}
// listChannels serves GET /v1/payout/channels: every channel, retired included.
func (h handlers) listChannels(c *gin.Context) {
var (
ds []platformpayout.Definition
l map[uuid.UUID]string
)
if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
var err error
if ds, err = platformpayout.ListChannels(ctx, tx, false); err != nil {
return err
}
ids := make([]uuid.UUID, len(ds))
for i, d := range ds {
ids[i] = d.ID
}
l, err = labels(ctx, tx, kindChannel, ids)
return err
}); err != nil {
_ = c.Error(mapErr(err))
return
}
out := make([]channelDTO, 0, len(ds))
for _, d := range ds {
out = append(out, toChannelDTO(d, l))
}
c.JSON(http.StatusOK, gin.H{"channels": out})
}
type createChannelBody struct {
Key string `json:"key"`
Names platformpayout.Names `json:"names"`
SettlementBehaviour string `json:"settlement_behaviour"`
CreditAccountID uuid.UUID `json:"credit_account_id"`
FieldSchema []platformpayout.Field `json:"field_schema"`
AllowedCurrencies []string `json:"allowed_currencies"`
AllowedClaimTypes []string `json:"allowed_claim_types"`
MaxAmount *money.Amount `json:"max_amount"`
RequiresSettlementProof bool `json:"requires_settlement_proof"`
SortOrder int `json:"sort_order"`
Active *bool `json:"active"`
}
// createChannel serves POST /v1/payout/channels. Every rule is the
// platform's; the handler only refuses what cannot be a request at all.
func (h handlers) createChannel(c *gin.Context) {
var body createChannelBody
if err := decodeStrict(c, &body); err != nil {
_ = c.Error(err)
return
}
if blank(body.Key) || body.CreditAccountID == uuid.Nil || blank(body.SettlementBehaviour) {
_ = c.Error(apperr.New(apperr.KindValidation, "key, names, settlement_behaviour and credit_account_id are required"))
return
}
in := platformpayout.ChannelInput{Key: body.Key, Names: body.Names, SettlementBehaviour: body.SettlementBehaviour,
CreditAccountID: body.CreditAccountID, Fields: body.FieldSchema, AllowedCurrencies: body.AllowedCurrencies,
AllowedClaimTypes: body.AllowedClaimTypes, MaxAmount: body.MaxAmount,
RequiresSettlementProof: body.RequiresSettlementProof, SortOrder: body.SortOrder,
Inactive: body.Active != nil && !*body.Active}
var (
d platformpayout.Definition
l map[uuid.UUID]string
)
if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
var err error
if d, err = platformpayout.CreateChannel(ctx, tx, h.deps.Clock, in); err != nil {
return err
}
l, err = labels(ctx, tx, kindChannel, []uuid.UUID{d.ID})
return err
}); err != nil {
_ = c.Error(mapErr(err))
return
}
c.JSON(http.StatusCreated, toChannelDTO(d, l))
}
type updateChannelBody struct {
Names optional[platformpayout.Names] `json:"names"`
CreditAccountID optional[uuid.UUID] `json:"credit_account_id"`
FieldSchema optional[[]platformpayout.Field] `json:"field_schema"`
AllowedCurrencies optional[[]string] `json:"allowed_currencies"`
AllowedClaimTypes optional[[]string] `json:"allowed_claim_types"`
MaxAmount optional[money.Amount] `json:"max_amount"` // null clears the cap
RequiresSettlementProof optional[bool] `json:"requires_settlement_proof"`
SortOrder optional[int] `json:"sort_order"`
Active optional[bool] `json:"active"`
}
// updateChannel serves PATCH /v1/payout/channels/:id. key and
// settlement_behaviour are fixed for life (Ruling P7): the body has no field
// for either, so sending one is a 400 from decodeStrict.
func (h handlers) updateChannel(c *gin.Context) {
id, err := parseID(c, "id")
if err != nil {
_ = c.Error(err)
return
}
var body updateChannelBody
if err := decodeStrict(c, &body); err != nil {
_ = c.Error(err)
return
}
var p platformpayout.ChannelPatch
for _, f := range []struct {
set, null bool
name string
}{
{body.Names.Set, body.Names.Value == nil, "names"},
{body.CreditAccountID.Set, body.CreditAccountID.Value == nil, "credit_account_id"},
{body.FieldSchema.Set, body.FieldSchema.Value == nil, "field_schema"},
{body.AllowedCurrencies.Set, body.AllowedCurrencies.Value == nil, "allowed_currencies"},
{body.AllowedClaimTypes.Set, body.AllowedClaimTypes.Value == nil, "allowed_claim_types"},
{body.RequiresSettlementProof.Set, body.RequiresSettlementProof.Value == nil, "requires_settlement_proof"},
{body.SortOrder.Set, body.SortOrder.Value == nil, "sort_order"},
{body.Active.Set, body.Active.Value == nil, "active"},
} {
if f.set && f.null {
_ = c.Error(apperr.New(apperr.KindValidation, f.name+" cannot be null"))
return
}
}
p.Names, p.CreditAccountID, p.Fields = body.Names.Value, body.CreditAccountID.Value, body.FieldSchema.Value
p.AllowedCurrencies, p.AllowedClaimTypes = body.AllowedCurrencies.Value, body.AllowedClaimTypes.Value
p.RequiresSettlementProof, p.SortOrder, p.IsActive = body.RequiresSettlementProof.Value, body.SortOrder.Value, body.Active.Value
if body.MaxAmount.Set {
p.MaxAmount = &body.MaxAmount.Value
}
var (
d platformpayout.Definition
l map[uuid.UUID]string
)
if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
var err error
if d, err = platformpayout.UpdateChannel(ctx, tx, h.deps.Clock, id, p); err != nil {
return err
}
l, err = labels(ctx, tx, kindChannel, []uuid.UUID{d.ID})
return err
}); err != nil {
_ = c.Error(mapErr(err))
return
}
c.JSON(http.StatusOK, toChannelDTO(d, l))
}

registry_handlers.go:

package payout
type registryEntryDTO struct {
ID uuid.UUID `json:"id"`
Registry string `json:"registry"`
Key string `json:"key"`
Name string `json:"name"`
Label string `json:"label"`
SortOrder int `json:"sort_order"`
Active bool `json:"active"`
}
func toRegistryEntryDTO(e platformpayout.RegistryEntry, l map[uuid.UUID]string) registryEntryDTO {
return registryEntryDTO{ID: e.ID, Registry: e.Registry, Key: e.Key, Name: e.Name, Label: label(l, e.ID, e.Name),
SortOrder: e.SortOrder, Active: e.IsActive}
}
// listRegistryEntries serves GET /v1/payout/registry-entries[?registry=my_banks].
func (h handlers) listRegistryEntries(c *gin.Context) {
registry := c.Query("registry")
var (
es []platformpayout.RegistryEntry
l map[uuid.UUID]string
)
if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
var err error
if es, err = platformpayout.ListRegistryEntries(ctx, tx, registry, false); err != nil {
return err
}
ids := make([]uuid.UUID, len(es))
for i, e := range es {
ids[i] = e.ID
}
l, err = labels(ctx, tx, kindRegistryEntry, ids)
return err
}); err != nil {
_ = c.Error(mapErr(err))
return
}
out := make([]registryEntryDTO, 0, len(es))
for _, e := range es {
out = append(out, toRegistryEntryDTO(e, l))
}
c.JSON(http.StatusOK, gin.H{"entries": out})
}
type createRegistryEntryBody struct {
Registry string `json:"registry"`
Key string `json:"key"`
Names platformpayout.Names `json:"names"`
SortOrder int `json:"sort_order"`
}
func (h handlers) createRegistryEntry(c *gin.Context) {
var body createRegistryEntryBody
if err := decodeStrict(c, &body); err != nil {
_ = c.Error(err)
return
}
var (
e platformpayout.RegistryEntry
l map[uuid.UUID]string
)
if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
var err error
if e, err = platformpayout.CreateRegistryEntry(ctx, tx, h.deps.Clock, platformpayout.RegistryEntryInput{
Registry: body.Registry, Key: body.Key, Names: body.Names, SortOrder: body.SortOrder}); err != nil {
return err
}
l, err = labels(ctx, tx, kindRegistryEntry, []uuid.UUID{e.ID})
return err
}); err != nil {
_ = c.Error(mapErr(err))
return
}
c.JSON(http.StatusCreated, toRegistryEntryDTO(e, l))
}
type updateRegistryEntryBody struct {
Names optional[platformpayout.Names] `json:"names"`
SortOrder optional[int] `json:"sort_order"`
Active optional[bool] `json:"active"`
}
func (h handlers) updateRegistryEntry(c *gin.Context) {
id, err := parseID(c, "id")
if err != nil {
_ = c.Error(err)
return
}
var body updateRegistryEntryBody
if err := decodeStrict(c, &body); err != nil {
_ = c.Error(err)
return
}
if (body.Names.Set && body.Names.Value == nil) || (body.SortOrder.Set && body.SortOrder.Value == nil) ||
(body.Active.Set && body.Active.Value == nil) {
_ = c.Error(apperr.New(apperr.KindValidation, "names, sort_order and active cannot be null"))
return
}
var (
e platformpayout.RegistryEntry
l map[uuid.UUID]string
)
if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
var err error
if e, err = platformpayout.UpdateRegistryEntry(ctx, tx, h.deps.Clock, id, platformpayout.RegistryEntryPatch{
Names: body.Names.Value, SortOrder: body.SortOrder.Value, IsActive: body.Active.Value}); err != nil {
return err
}
l, err = labels(ctx, tx, kindRegistryEntry, []uuid.UUID{e.ID})
return err
}); err != nil {
_ = c.Error(mapErr(err))
return
}
c.JSON(http.StatusOK, toRegistryEntryDTO(e, l))
}

api/openapi.yaml — append the six operations under paths: and the schemas under components.schemas, in the house style of /accounting/accounts (every operation lists 401 via Unauthenticated, 403 naming payout.channel.manage, 400 via BadRequestBody where there is a body, 404/409/422 where mapErr produces them). The first path, as the pattern for the rest:

/payout/channels:
get:
operationId: listPayoutChannels
summary: Payout channels
description: >
Every payout channel of the organization, retired ones included, in
sort order. Each carries label, its name in the request's locale.
Requires payout.channel.manage.
responses:
'200':
description: The channels
content:
application/json:
schema:
$ref: '#/components/schemas/PayoutChannelList'
'401':
$ref: '#/components/responses/Unauthenticated'
'403':
description: The caller does not hold payout.channel.manage.
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
Method Path operationId Request 2xx Errors
GET /payout/channels listPayoutChannels — 200 PayoutChannelList 401 403
POST /payout/channels createPayoutChannel CreatePayoutChannelRequest 201 PayoutChannel 400 401 403 409 422
PATCH /payout/channels/{id} updatePayoutChannel UpdatePayoutChannelRequest 200 PayoutChannel 400 401 403 404 422
GET /payout/registry-entries listPayoutRegistryEntries query registry (optional string) 200 PayoutRegistryEntryList 401 403
POST /payout/registry-entries createPayoutRegistryEntry CreatePayoutRegistryEntryRequest 201 PayoutRegistryEntry 400 401 403 409 422
PATCH /payout/registry-entries/{id} updatePayoutRegistryEntry UpdatePayoutRegistryEntryRequest 200 PayoutRegistryEntry 400 401 403 404 409 422

Schemas:

SettlementBehaviour:
type: string
enum: [external, petty_cash]
description: >
Closed set (spec 9.2). external — the transfer happens outside the
system. petty_cash — paid from a holder's float.
PayoutNames:
type: object
description: One string per locale. en and ms are required; zh is optional.
required: [en, ms]
additionalProperties: false
properties:
en: {type: string}
ms: {type: string}
zh: {type: string}
PayoutFieldOption:
type: object
required: [key, label]
additionalProperties: false
properties:
key: {type: string}
label: {$ref: '#/components/schemas/PayoutNames'}
PayoutField:
type: object
required: [key, type, label]
additionalProperties: false
properties:
key: {type: string, pattern: '^[a-z][a-z0-9_]{0,39}$'}
type:
type: string
enum: [text, select, registry_ref, phone, account_no, file, address]
required: {type: boolean}
max: {type: integer, minimum: 1, maximum: 500}
sensitivity: {type: string, enum: [masked]}
label: {$ref: '#/components/schemas/PayoutNames'}
help: {$ref: '#/components/schemas/PayoutNames'}
options:
type: array
items: {$ref: '#/components/schemas/PayoutFieldOption'}
registry: {type: string}
PayoutChannel:
type: object
required: [id, key, name, label, settlement_behaviour, credit_account_id, field_schema,
allowed_currencies, allowed_claim_types, max_amount, requires_settlement_proof, sort_order, active]
properties:
id: {type: string, format: uuid}
key: {type: string}
name: {type: string}
label: {type: string}
settlement_behaviour: {$ref: '#/components/schemas/SettlementBehaviour'}
credit_account_id: {type: string, format: uuid}
field_schema:
type: array
items: {$ref: '#/components/schemas/PayoutField'}
allowed_currencies: {type: array, items: {type: string}}
allowed_claim_types: {type: array, items: {type: string}}
max_amount:
allOf: [{$ref: '#/components/schemas/Money'}]
nullable: true
requires_settlement_proof: {type: boolean}
sort_order: {type: integer}
active: {type: boolean}
PayoutChannelList:
type: object
required: [channels]
properties:
channels:
type: array
items: {$ref: '#/components/schemas/PayoutChannel'}
CreatePayoutChannelRequest:
type: object
required: [key, names, settlement_behaviour, credit_account_id, field_schema, allowed_currencies]
additionalProperties: false
properties:
key: {type: string}
names: {$ref: '#/components/schemas/PayoutNames'}
settlement_behaviour: {$ref: '#/components/schemas/SettlementBehaviour'}
credit_account_id: {type: string, format: uuid}
field_schema: {type: array, items: {$ref: '#/components/schemas/PayoutField'}}
allowed_currencies: {type: array, items: {type: string}}
allowed_claim_types: {type: array, items: {type: string}}
max_amount: {$ref: '#/components/schemas/Money'}
requires_settlement_proof: {type: boolean}
sort_order: {type: integer}
active: {type: boolean}
UpdatePayoutChannelRequest:
type: object
additionalProperties: false
description: key and settlement_behaviour are fixed for life and cannot be sent.
properties:
names: {$ref: '#/components/schemas/PayoutNames'}
credit_account_id: {type: string, format: uuid}
field_schema: {type: array, items: {$ref: '#/components/schemas/PayoutField'}}
allowed_currencies: {type: array, items: {type: string}}
allowed_claim_types: {type: array, items: {type: string}}
max_amount:
allOf: [{$ref: '#/components/schemas/Money'}]
nullable: true
description: null clears the cap.
requires_settlement_proof: {type: boolean}
sort_order: {type: integer}
active: {type: boolean}
PayoutRegistryEntry:
type: object
required: [id, registry, key, name, label, sort_order, active]
properties:
id: {type: string, format: uuid}
registry: {type: string}
key: {type: string}
name: {type: string}
label: {type: string}
sort_order: {type: integer}
active: {type: boolean}
PayoutRegistryEntryList:
type: object
required: [entries]
properties:
entries: {type: array, items: {$ref: '#/components/schemas/PayoutRegistryEntry'}}
CreatePayoutRegistryEntryRequest:
type: object
required: [registry, key, names]
additionalProperties: false
properties:
registry: {type: string}
key: {type: string}
names: {$ref: '#/components/schemas/PayoutNames'}
sort_order: {type: integer}
UpdatePayoutRegistryEntryRequest:
type: object
additionalProperties: false
properties:
names: {$ref: '#/components/schemas/PayoutNames'}
sort_order: {type: integer}
active: {type: boolean}

Composition:

  • constants.yaml: append - payout.channel.manage to the end of permissions:.

  • internal/app/app.go: Payout payout.Deps on Deps (import "github.com/mcom/erp/internal/modules/payout"); payout.Register(api, deps.Payout) after accounting.Register.

  • internal/app/routes_test.go: append "payout": {payout.PermissionChannelManage}, to composedModules.

  • cmd/api/main.go: import the module and pass Payout: payout.Deps{DB: database, Clock: clock.System()}; install the payout keyring at boot (Step 7).

  • cmd/migrate/main.go, cmd/orgadmin/main.go: _ "github.com/mcom/erp/internal/modules/payout". Their wiring_test.go: append "github.com/mcom/erp/internal/modules/payout", to wantModules and "payout.channel.manage", // modules/payout to wantPermissions. cmd/worker is not touched (Ruling P28).

  • Step 1: Write the failing tests

routes_test.go — the accounting pattern:

func TestEveryRouteRequiresItsExactPermission(t *testing.T) {
gin.SetMode(gin.TestMode)
httpx.ResetAuthorizedRoutes()
r, api := httpx.NewRouter(httpx.Deps{})
payout.Register(api, payout.Deps{})
m := payout.PermissionChannelManage
want := map[string]string{
"GET /v1/payout/channels": m,
"POST /v1/payout/channels": m,
"PATCH /v1/payout/channels/:id": m,
"GET /v1/payout/registry-entries": m,
"POST /v1/payout/registry-entries": m,
"PATCH /v1/payout/registry-entries/:id": m,
}
require.Equal(t, want, httpx.AuthorizedRoutes())
require.Equal(t, "payout.channel.manage", payout.PermissionChannelManage)
var registered []string
for _, ri := range r.Routes() {
if strings.HasPrefix(ri.Path, "/v1/payout/") && !strings.HasPrefix(ri.Path, "/v1/payout/me/") {
registered = append(registered, ri.Method+" "+ri.Path)
}
}
require.Len(t, registered, len(want), "routes: %v", registered)
}

vocab_test.go:

// TestSettlementBehaviourEnumMatchesThePlatform pins the OpenAPI enum to the
// platform's closed set (Ruling P20); the DB CHECK is pinned in Task 1.
func TestSettlementBehaviourEnumMatchesThePlatform(t *testing.T) {
gen := []string{string(api.SettlementBehaviourExternal), string(api.SettlementBehaviourPettyCash)}
require.ElementsMatch(t, platformpayout.SettlementBehaviours, gen)
for _, b := range platformpayout.SettlementBehaviours {
require.True(t, api.SettlementBehaviour(b).Valid(), b)
}
}

channel_handlers_test.go — copy the env harness of internal/modules/accounting/chart_handlers_test.go (the alwaysLive, discard, env, newEnv, grant, mint, do helpers: accountingtest.New, provisioning.Run with member.RegisteredPermissionSet(), a token.Signer, httpx.NewRouter), registering payout.Register(api, payout.Deps{DB: acc.DB, Clock: acc.Clock}), with two members: admin (holds payout.channel.manage) and plain (holds nothing). The harness builds on accountingtest, not payouttest.New, so newEnv installs the keyring itself as its first line: payouttest.UseKeyring(t, payouttest.NewKey(t, "test-1"), nil) — without it every profile call returns ErrNoKeyring. Because the harness runs the real provisioning, the org already has the seven Malaysia channels.

func TestListChannelsNeedsTheManagePermission(t *testing.T) {
e := newEnv(t)
w := e.do(t, "GET", "/v1/payout/channels", e.adminTok, nil)
require.Equal(t, http.StatusOK, w.Code) // PRESENCE
var body struct{ Channels []map[string]any }
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Len(t, body.Channels, 7)
require.Equal(t, http.StatusForbidden, e.do(t, "GET", "/v1/payout/channels", e.plainTok, nil).Code)
}
func TestListChannelsLabelsInTheRequestLocale(t *testing.T) {
e := newEnv(t)
w := e.do(t, "GET", "/v1/payout/channels", e.adminTok, nil, "Accept-Language", "zh")
require.Contains(t, w.Body.String(), `"label":"银行转账"`)
require.Contains(t, w.Body.String(), `"name":"Bank transfer"`)
}
func TestCreateChannelReturns201AndRefusesBadDefinitions(t *testing.T) {
e := newEnv(t)
bankID := e.accountID(t, "1520") // helper: chart.GetAccountByCode inside e.acc.Tx
body := map[string]any{"key": "wise_myr", "names": map[string]string{"en": "Wise", "ms": "Wise"},
"settlement_behaviour": "external", "credit_account_id": bankID, "field_schema": []any{},
"allowed_currencies": []string{"MYR"}, "max_amount": "5000.00"}
w := e.do(t, "POST", "/v1/payout/channels", e.adminTok, body)
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
require.Contains(t, w.Body.String(), `"max_amount":"5000.0000"`)
require.Equal(t, http.StatusConflict, e.do(t, "POST", "/v1/payout/channels", e.adminTok, body).Code)
body["key"], body["credit_account_id"] = "on_expense", e.accountID(t, "6100")
w = e.do(t, "POST", "/v1/payout/channels", e.adminTok, body)
require.Equal(t, http.StatusUnprocessableEntity, w.Code)
require.Contains(t, w.Body.String(), "bank or cash journal")
body["key"], body["credit_account_id"], body["max_amount"] = "num", bankID, 5000
require.Equal(t, http.StatusBadRequest, e.do(t, "POST", "/v1/payout/channels", e.adminTok, body).Code,
"money as a JSON number is refused")
}
func TestUpdateChannelCannotChangeKeyOrBehaviour(t *testing.T) {
e := newEnv(t)
id := e.channelID(t, "cash") // helper: payout.ListChannels, find by key
require.Equal(t, http.StatusOK,
e.do(t, "PATCH", "/v1/payout/channels/"+id.String(), e.adminTok, map[string]any{"sort_order": 99}).Code)
for _, field := range []string{"key", "settlement_behaviour"} {
w := e.do(t, "PATCH", "/v1/payout/channels/"+id.String(), e.adminTok, map[string]any{field: "x"})
require.Equal(t, http.StatusBadRequest, w.Code, field)
}
}
func TestUpdateChannelMaxAmountNullClearsIt(t *testing.T) {
e := newEnv(t)
id := e.channelID(t, "cash")
w := e.do(t, "PATCH", "/v1/payout/channels/"+id.String(), e.adminTok, map[string]any{"max_amount": "300"})
require.Contains(t, w.Body.String(), `"max_amount":"300.0000"`)
w = e.do(t, "PATCH", "/v1/payout/channels/"+id.String(), e.adminTok, `{"max_amount": null}`)
require.Contains(t, w.Body.String(), `"max_amount":null`)
}
func TestAnotherOrgsChannelIs404(t *testing.T) {
e := newEnv(t)
own := e.channelID(t, "cash")
require.Equal(t, http.StatusOK, // PRESENCE
e.do(t, "PATCH", "/v1/payout/channels/"+own.String(), e.adminTok, map[string]any{"sort_order": 1}).Code)
foreign := e.otherOrgChannel(t) // helper: payouttest-style admin insert into a second org
require.Equal(t, http.StatusNotFound,
e.do(t, "PATCH", "/v1/payout/channels/"+foreign.String(), e.adminTok, map[string]any{"sort_order": 1}).Code)
}

registry_handlers_test.go:

func TestRegistryEntriesListCreateAndRetire(t *testing.T) {
e := newEnv(t)
w := e.do(t, "GET", "/v1/payout/registry-entries?registry=my_ewallets", e.adminTok, nil)
require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), `"key":"grabpay"`)
require.NotContains(t, w.Body.String(), `"key":"maybank"`)
w = e.do(t, "POST", "/v1/payout/registry-entries", e.adminTok, map[string]any{
"registry": "my_ewallets", "key": "setel", "names": map[string]string{"en": "Setel", "ms": "Setel"}})
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
var created struct{ ID uuid.UUID }
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &created))
require.Equal(t, http.StatusOK, e.do(t, "PATCH", "/v1/payout/registry-entries/"+created.ID.String(),
e.adminTok, map[string]any{"active": false}).Code)
}
func TestRetiringTheLastUsedEntryIs409(t *testing.T) {
e := newEnv(t)
// Retire every crypto network: the crypto channel is seeded inactive, so this succeeds…
ids := e.registryIDs(t, "crypto_networks") // helper: GET + decode
for _, id := range ids {
require.Equal(t, http.StatusOK, e.do(t, "PATCH", "/v1/payout/registry-entries/"+id.String(),
e.adminTok, map[string]any{"active": false}).Code)
}
// …while retiring every bank refuses on the last one: bank_transfer is active.
banks := e.registryIDs(t, "my_banks")
for _, id := range banks[:len(banks)-1] {
require.Equal(t, http.StatusOK, e.do(t, "PATCH", "/v1/payout/registry-entries/"+id.String(),
e.adminTok, map[string]any{"active": false}).Code)
}
require.Equal(t, http.StatusConflict, e.do(t, "PATCH", "/v1/payout/registry-entries/"+banks[len(banks)-1].String(),
e.adminTok, map[string]any{"active": false}).Code)
}

The helpers accountID, channelID, otherOrgChannel, registryIDs live in channel_handlers_test.go: each opens e.acc.Tx (or e.acc.Another for the foreign org, inserting through e.acc.H.Admin as payouttest.SeedChannel does) and returns the id; registryIDs issues the GET and decodes entries[].id.

  • Step 2: Run and watch fail. go test ./internal/modules/payout/ -count=1 → FAIL: package does not exist.
  • Step 3: Implement the module files; then constants.yaml, api/openapi.yaml; then make generate.
  • Step 4: Compose (app.go, routes_test.go, cmd/api, cmd/migrate, cmd/orgadmin, the two wiring_test.go).
  • Step 5: Write the failing configuration test (internal/kernel/config/payoutkeys_test.go, package config_test; reuses realKey and envExampleValues from placeholders_test.go)
package config_test
import (
"crypto/rand"
"encoding/base64"
"testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/config"
)
// TestPayoutDetailsKeys is Ruling P30: the api refuses to start without a
// real payout-details key, and a rotation carries at most one previous key.
func TestPayoutDetailsKeys(t *testing.T) {
key, prev := realKey(t), realKey(t)
t.Run("an active key alone", func(t *testing.T) { // PRESENCE
t.Setenv("PAYOUT_DETAILS_KEY", "p1:"+key)
t.Setenv("PAYOUT_DETAILS_KEY_PREVIOUS", "")
got, err := config.PayoutDetailsKeysFromEnv()
require.NoError(t, err)
require.Equal(t, "p1", got.Active.ID)
require.Len(t, got.Active.Secret, 32)
require.Nil(t, got.Previous)
})
t.Run("a rotation in progress", func(t *testing.T) {
t.Setenv("PAYOUT_DETAILS_KEY", "p2:"+key)
t.Setenv("PAYOUT_DETAILS_KEY_PREVIOUS", "p1:"+prev)
got, err := config.PayoutDetailsKeysFromEnv()
require.NoError(t, err)
require.Equal(t, "p2", got.Active.ID)
require.NotNil(t, got.Previous)
require.Equal(t, "p1", got.Previous.ID)
})
short := make([]byte, 16)
_, err := rand.Read(short)
require.NoError(t, err)
example := envExampleValues(t)
for name, c := range map[string]struct{ active, previous, want string }{
"unset": {"", "", "PAYOUT_DETAILS_KEY is required"},
"the example's placeholder": {example["PAYOUT_DETAILS_KEY"], "", "openssl rand"},
"no key id": {key, "", "kid:base64secret"},
"bad key id": {"p 1:" + key, "", "kid:base64secret"},
"short key": {"p1:" + base64.StdEncoding.EncodeToString(short), "", "32 bytes"},
"not base64": {"p1:***", "", "PAYOUT_DETAILS_KEY"},
"previous shares the id": {"p1:" + key, "p1:" + prev, "same key id"},
"previous is a placeholder": {"p1:" + key, "p0:REPLACE_ME_WITH_BASE64_OF_32_RANDOM_BYTES", "PAYOUT_DETAILS_KEY_PREVIOUS"},
} {
t.Run(name, func(t *testing.T) {
t.Setenv("PAYOUT_DETAILS_KEY", c.active)
t.Setenv("PAYOUT_DETAILS_KEY_PREVIOUS", c.previous)
_, err := config.PayoutDetailsKeysFromEnv()
require.Error(t, err)
require.Contains(t, err.Error(), c.want)
require.NotContains(t, err.Error(), key, "an error never carries key material")
})
}
}

Run: go test ./internal/kernel/config/ -run PayoutDetailsKeys -count=1 Expected: FAIL — undefined config.PayoutDetailsKeysFromEnv.

  • Step 6: Implement the key configuration, .env.example and the deploy note

Append to internal/kernel/config/config.go (add "regexp" to its imports):

// KeyedSecret is one "kid:base64secret" key: its id and its decoded bytes.
type KeyedSecret struct {
ID string
Secret []byte
}
// PayoutDetailsKeys is the AES-256 keyring for members' payout details
// (Plan 6, Rulings P30, P32). Active seals and opens; Previous, set only
// during a rotation, opens what the key being rotated away from sealed.
type PayoutDetailsKeys struct {
Active KeyedSecret
Previous *KeyedSecret
}
// PayoutDetailsKeysFromEnv reads PAYOUT_DETAILS_KEY (required) and
// PAYOUT_DETAILS_KEY_PREVIOUS (optional), each "kid:base64secret" decoding
// to exactly 32 bytes.
//
// It is its own function rather than part of Load because only a binary that
// constructs the payout module needs it — cmd/api. cmd/worker calls Load and
// does not link payout (Plan 6, Ruling P28), and must not be refused for a
// key it never uses. Errors name the variable and the key id, never the key.
func PayoutDetailsKeysFromEnv() (PayoutDetailsKeys, error) {
raw := os.Getenv("PAYOUT_DETAILS_KEY")
if strings.TrimSpace(raw) == "" {
return PayoutDetailsKeys{}, fmt.Errorf(
"PAYOUT_DETAILS_KEY is required: members' payout details are encrypted with it " +
"(generate one with `echo \"p1:$(openssl rand -base64 32)\"`)")
}
active, err := parseKeyedSecret("PAYOUT_DETAILS_KEY", raw)
if err != nil {
return PayoutDetailsKeys{}, err
}
out := PayoutDetailsKeys{Active: active}
if prevRaw := os.Getenv("PAYOUT_DETAILS_KEY_PREVIOUS"); strings.TrimSpace(prevRaw) != "" {
prev, err := parseKeyedSecret("PAYOUT_DETAILS_KEY_PREVIOUS", prevRaw)
if err != nil {
return PayoutDetailsKeys{}, err
}
if prev.ID == active.ID {
return PayoutDetailsKeys{}, fmt.Errorf(
"PAYOUT_DETAILS_KEY_PREVIOUS has the same key id %q as PAYOUT_DETAILS_KEY; a rotation needs a new id", prev.ID)
}
out.Previous = &prev
}
return out, nil
}
// keyIDPattern mirrors internal/platform/payout's (and the
// member_payout_profile_kid_format CHECK): the id is stored beside every
// ciphertext.
var keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,32}$`)
func parseKeyedSecret(name, raw string) (KeyedSecret, error) {
kid, secret, ok := strings.Cut(strings.TrimSpace(raw), ":")
if err := refuseKnownSecret(name, secret); err != nil {
return KeyedSecret{}, err
}
if !ok || !keyIDPattern.MatchString(kid) {
return KeyedSecret{}, fmt.Errorf(
"%s must be kid:base64secret, the kid 1 to 32 letters, digits, _ or -", name)
}
key, err := base64.StdEncoding.DecodeString(secret)
if err != nil {
return KeyedSecret{}, fmt.Errorf("%s (key id %q) is not valid base64", name, kid)
}
if len(key) != 32 {
return KeyedSecret{}, fmt.Errorf("%s (key id %q) must decode to 32 bytes, got %d", name, kid, len(key))
}
return KeyedSecret{ID: kid, Secret: key}, nil
}

Append to .env.example, after TOTP_ENCRYPTION_KEY:

Terminal window
# Members' payout details (bank account numbers, DuitNow IDs, e-wallet IDs,
# payee names) are encrypted at rest with AES-256-GCM under this key
# (Malaysia PDPA 2010, security principle). "kid:base64secret", the secret
# exactly 32 bytes; the kid is stored beside every ciphertext so the key can
# be rotated (docs/DEPLOY_NOTES.md, Plan 6). REQUIRED by the api container;
# the worker does not read it.
#
# A placeholder, refused at boot. Generate with:
# echo "p1:$(openssl rand -base64 32)"
# Losing this key loses every saved payout detail AND every masked value in
# every claim's settlement snapshot, which is kept seven years. Back it up
# outside the database backups — a backup holding both defeats the point.
PAYOUT_DETAILS_KEY=p1:REPLACE_ME_WITH_BASE64_OF_32_RANDOM_BYTES
# The key a rotation is moving away from, in the same shape: it opens, it
# never seals. Empty except during a rotation.
PAYOUT_DETAILS_KEY_PREVIOUS=

Prepend to docs/DEPLOY_NOTES.md, directly under its --- (newest first):

## Plan 6 — payout channels
### 1. `PAYOUT_DETAILS_KEY` is now REQUIRED by the api container
**What it is.** The AES-256 key that encrypts members' saved payout details — account
numbers, DuitNow and e-wallet IDs, payee names — and the masked values inside every
claim's settlement snapshot (Malaysia PDPA 2010, security principle). Format
`kid:base64secret`, the secret exactly 32 bytes:
```sh
PAYOUT_DETAILS_KEY=p1:$(openssl rand -base64 32) # generate once per environment
```
**The api container refuses to boot** without it, with a malformed or short key, or with
the `.env.example` placeholder. The worker, `migrate` and `orgadmin` do not read it.
**Back it up separately from the database.** A database dump alone yields no account
number; that holds only while the key is not stored beside the dump. **Losing the key
loses every saved payout detail** (members re-enter them) **and every masked value in
every claim snapshot**, which the law requires kept seven years.
### 2. Rotating the key
Rotation is one active key plus at most one previous key (Ruling P32). Never reuse a kid.
1. Generate a new key with a **new kid**. Set `PAYOUT_DETAILS_KEY=<new kid>:<new secret>`
and `PAYOUT_DETAILS_KEY_PREVIOUS=<old kid>:<old secret>`. Deploy. New writes use the new
key; everything sealed under the old one still opens.
2. Re-encrypt everything under the old kid: members' profiles (`payout.ResealProfiles`,
per organization) and claim snapshots (`payout.ResealSnapshot`, on Plan 5's claims).
**The operator command that runs both across every organization ships with Plan 5.
Until it exists, do not remove `PAYOUT_DETAILS_KEY_PREVIOUS` and do not rotate again.**
3. Confirm nothing remains under the old kid, as the migration role:
`SELECT count(*) FROM member_payout_profile WHERE details_kid = '<old kid>';` → `0`
(and Plan 5's equivalent for claims). Only then clear `PAYOUT_DETAILS_KEY_PREVIOUS`.
Dropping a previous key before step 3 makes every value still sealed under it
unreadable (`ErrUnknownKey`) — for claim snapshots, a statutory record.
### 3. Departed members' saved payout details are purged
When a member is deactivated, their saved payout profiles are deleted and each deletion
is audited with masked values only (Ruling P33). Claim snapshots are not touched. No
deactivation path exists yet; the purge runs from the one that is built later.

Run: go test ./internal/kernel/config/ -count=1 Expected: PASS — TestPayoutDetailsKeys, and the existing .env.example drift test now finds the two new variables both read and documented.

  • Step 7: Install the keyring in cmd/api

In cmd/api/main.go, after cfg, err := config.Load() succeeds (import the platform package as platformpayout "github.com/mcom/erp/internal/platform/payout"):

// Members' payout details are sealed under this keyring (Plan 6,
// Rulings P15, P30). Required: the api refuses to start without it
// rather than serve payout profiles it cannot read or write.
payoutKeys, err := config.PayoutDetailsKeysFromEnv()
if err != nil {
log.Fatalf("config: %v", err)
}
var previousPayoutKey *platformpayout.Key
if p := payoutKeys.Previous; p != nil {
previousPayoutKey = &platformpayout.Key{ID: p.ID, Secret: p.Secret}
}
payoutKeyring, err := platformpayout.NewKeyring(
platformpayout.Key{ID: payoutKeys.Active.ID, Secret: payoutKeys.Active.Secret}, previousPayoutKey)
if err != nil {
log.Fatalf("payout keyring: %v", err)
}
platformpayout.InstallKeyring(payoutKeyring)

Run: go build ./cmd/api/ → succeeds.

  • Step 8: Run the composition’s guards and the touched packages

Run: go test ./internal/modules/payout/ ./internal/app/ ./cmd/... ./internal/kernel/config/ ./internal/kernel/vocab/ ./internal/gen/ ./internal/kernel/layering/ -count=1 Expected: PASS — including TestEveryRouteIsAuthorized, TestEveryModuleContributesRoutesAndPermissions, TestTheOpenAPIDocumentSaysWhatTheRouterEnforces, TestEveryBinaryLinksTheSameModules, the vocab and API drift tests. Then grep -n 'float' internal/gen/api/api.gen.go prints nothing new.

  • Step 9: Commit
Terminal window
git add internal/modules/payout constants.yaml internal/kernel/vocab packages/contracts api/openapi.yaml internal/gen/api \
internal/app cmd/api cmd/migrate cmd/orgadmin internal/kernel/config .env.example docs/DEPLOY_NOTES.md
git commit -m "Compose the payout module: channel and registry administration; require the payout-details key"

Task 7: Self-service routes — a member’s own payout details

Section titled “Task 7: Self-service routes — a member’s own payout details”

Files:

  • Create: internal/modules/payout/me_handlers.go
  • Modify: internal/modules/payout/routes.go (four registrations), api/openapi.yaml (+ make generate), api/public_routes.txt
  • Test: internal/modules/payout/me_handlers_test.go

Interfaces:

  • Consumes: platformpayout.ListChannels, ListRegistryEntries, ListProfiles, SaveProfile, DeleteProfile, ProfileView, FieldView (Tasks 2–4); httpx.IdentityFromContext; Task 6’s handlers, mapErr, labels, decodeStrict, parseID.
  • Produces: GET /v1/payout/me/channels, GET /v1/payout/me/profiles, PUT /v1/payout/me/profiles/:channel_id, DELETE /v1/payout/me/profiles/:channel_id (no permission; Ruling P21).

Add to Register, after the administrator routes:

// A member's own payout details (Ruling P21). No permission: each acts
// only on the member id in the verified token, never one from the
// request. Declared under [no-permission] in api/public_routes.txt.
g.GET("/payout/me/channels", h.myChannels)
g.GET("/payout/me/profiles", h.myProfiles)
g.PUT("/payout/me/profiles/:channel_id", h.saveMyProfile)
g.DELETE("/payout/me/profiles/:channel_id", h.deleteMyProfile)

me_handlers.go:

package payout
// caller is the verified member. The /payout/me routes run behind
// Authenticate and Tenant, so a missing identity is a wiring defect.
func caller(c *gin.Context) (uuid.UUID, bool) {
id, ok := httpx.IdentityFromContext(c.Request.Context())
if !ok {
_ = c.Error(apperr.New(apperr.KindUnauthorized, "authentication required"))
return uuid.Nil, false
}
return id.MemberID, true
}
type choiceDTO struct {
Key string `json:"key"`
Label string `json:"label"`
}
// myFieldDTO is a field definition plus, for a registry_ref, the registry's
// active entries — so a client renders a picker from one call.
type myFieldDTO struct {
platformpayout.Field
Choices []choiceDTO `json:"choices,omitempty"`
}
// myChannelDTO carries no GL binding: a member has no need of it.
type myChannelDTO struct {
ID uuid.UUID `json:"id"`
Key string `json:"key"`
Label string `json:"label"`
SettlementBehaviour string `json:"settlement_behaviour"`
FieldSchema []myFieldDTO `json:"field_schema"`
AllowedCurrencies []string `json:"allowed_currencies"`
AllowedClaimTypes []string `json:"allowed_claim_types"`
MaxAmount *money.Amount `json:"max_amount"`
}
// myChannels serves GET /v1/payout/me/channels: the active channels a member
// may save details for.
func (h handlers) myChannels(c *gin.Context) {
if _, ok := caller(c); !ok {
return
}
var out []myChannelDTO
if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
ds, err := platformpayout.ListChannels(ctx, tx, true)
if err != nil {
return err
}
ids := make([]uuid.UUID, len(ds))
for i, d := range ds {
ids[i] = d.ID
}
l, err := labels(ctx, tx, kindChannel, ids)
if err != nil {
return err
}
choices := map[string][]choiceDTO{}
out = make([]myChannelDTO, 0, len(ds))
for _, d := range ds {
dto := myChannelDTO{ID: d.ID, Key: d.Key, Label: label(l, d.ID, d.Name),
SettlementBehaviour: d.SettlementBehaviour, AllowedCurrencies: d.AllowedCurrencies,
AllowedClaimTypes: d.AllowedClaimTypes, MaxAmount: d.MaxAmount, FieldSchema: []myFieldDTO{}}
for _, f := range d.Fields {
fd := myFieldDTO{Field: f}
if f.Type == platformpayout.FieldRegistryRef {
if _, seen := choices[f.Registry]; !seen {
es, err := platformpayout.ListRegistryEntries(ctx, tx, f.Registry, true)
if err != nil {
return err
}
eids := make([]uuid.UUID, len(es))
for i, e := range es {
eids[i] = e.ID
}
el, err := labels(ctx, tx, kindRegistryEntry, eids)
if err != nil {
return err
}
cs := make([]choiceDTO, 0, len(es))
for _, e := range es {
cs = append(cs, choiceDTO{Key: e.Key, Label: label(el, e.ID, e.Name)})
}
choices[f.Registry] = cs
}
fd.Choices = choices[f.Registry]
}
dto.FieldSchema = append(dto.FieldSchema, fd)
}
out = append(out, dto)
}
return nil
}); err != nil {
_ = c.Error(mapErr(err))
return
}
c.JSON(http.StatusOK, gin.H{"channels": out})
}
type profileDTO struct {
ID uuid.UUID `json:"id"`
ChannelID uuid.UUID `json:"channel_id"`
ChannelKey string `json:"channel_key"`
Fields []platformpayout.FieldView `json:"fields"`
Valid bool `json:"valid"`
Problems []problemDTO `json:"problems"`
UpdatedAt time.Time `json:"updated_at"`
}
type problemDTO struct {
Key string `json:"key"`
Reason string `json:"reason"`
}
func toProfileDTO(v platformpayout.ProfileView) profileDTO {
d := profileDTO{ID: v.ID, ChannelID: v.ChannelID, ChannelKey: v.ChannelKey, Fields: v.Fields,
Valid: v.Valid, Problems: []problemDTO{}, UpdatedAt: v.UpdatedAt}
for _, p := range v.Problems {
d.Problems = append(d.Problems, problemDTO{Key: p.Key, Reason: p.Reason})
}
return d
}
func (h handlers) myProfiles(c *gin.Context) {
me, ok := caller(c)
if !ok {
return
}
var vs []platformpayout.ProfileView
if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
var err error
vs, err = platformpayout.ListProfiles(ctx, tx, me)
return err
}); err != nil {
_ = c.Error(mapErr(err))
return
}
out := make([]profileDTO, 0, len(vs))
for _, v := range vs {
out = append(out, toProfileDTO(v))
}
c.JSON(http.StatusOK, gin.H{"profiles": out})
}
type saveProfileBody struct {
Details map[string]string `json:"details"`
}
// saveMyProfile serves PUT /v1/payout/me/profiles/:channel_id — a full
// replace (Ruling P12). The response is masked like every other read.
func (h handlers) saveMyProfile(c *gin.Context) {
me, ok := caller(c)
if !ok {
return
}
channelID, err := parseID(c, "channel_id")
if err != nil {
_ = c.Error(err)
return
}
var body saveProfileBody
if err := decodeStrict(c, &body); err != nil {
_ = c.Error(err)
return
}
if body.Details == nil {
_ = c.Error(apperr.New(apperr.KindValidation, "details is required"))
return
}
var v platformpayout.ProfileView
if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
var err error
v, err = platformpayout.SaveProfile(ctx, tx, h.deps.Clock, me, channelID, body.Details)
return err
}); err != nil {
_ = c.Error(mapErr(err))
return
}
c.JSON(http.StatusOK, toProfileDTO(v))
}
func (h handlers) deleteMyProfile(c *gin.Context) {
me, ok := caller(c)
if !ok {
return
}
channelID, err := parseID(c, "channel_id")
if err != nil {
_ = c.Error(err)
return
}
if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
return platformpayout.DeleteProfile(ctx, tx, h.deps.Clock, me, channelID)
}); err != nil {
_ = c.Error(mapErr(err))
return
}
c.Status(http.StatusNoContent)
}

api/public_routes.txt — append inside [no-permission], after the identity block:

# --- a member's own payout details (Plan 6, Ruling P21) -----------------
# These run behind Authenticate and Tenant, so the caller is a verified
# member of a live session in this organization. They carry no permission
# because there is no authority to grant: every one acts on the caller's own
# member id, taken from the signed claim and never from the request, and the
# platform functions they call name that member id in every WHERE clause.
#
# me/channels lists active channels and their field schemas — the
# form a member fills in. No GL binding is exposed.
# me/profiles lists the caller's saved details, masked (Ruling P12).
# me/profiles/:id saves (PUT) or deletes the caller's details for one
# channel; each change is audited with masked values.
#
# Gating these on a permission would mean a member could not keep their own
# bank details current, and seeded non-Owner roles hold no grants.
GET /v1/payout/me/channels
GET /v1/payout/me/profiles
PUT /v1/payout/me/profiles/:channel_id
DELETE /v1/payout/me/profiles/:channel_id

api/openapi.yaml — four operations (inheriting the default bearerAuth), each description beginning “The caller’s own …; no permission is required”:

Method Path operationId Request 2xx Errors
GET /payout/me/channels listMyPayoutChannels — 200 MyPayoutChannelList 401
GET /payout/me/profiles listMyPayoutProfiles — 200 PayoutProfileList 401
PUT /payout/me/profiles/{channel_id} saveMyPayoutProfile SavePayoutProfileRequest 200 PayoutProfile 400 401 403 404 409 422
DELETE /payout/me/profiles/{channel_id} deleteMyPayoutProfile — 204 401 404
MyPayoutField:
allOf:
- $ref: '#/components/schemas/PayoutField'
- type: object
properties:
choices:
type: array
items:
type: object
required: [key, label]
properties:
key: {type: string}
label: {type: string}
MyPayoutChannel:
type: object
required: [id, key, label, settlement_behaviour, field_schema, allowed_currencies, allowed_claim_types, max_amount]
properties:
id: {type: string, format: uuid}
key: {type: string}
label: {type: string}
settlement_behaviour: {$ref: '#/components/schemas/SettlementBehaviour'}
field_schema: {type: array, items: {$ref: '#/components/schemas/MyPayoutField'}}
allowed_currencies: {type: array, items: {type: string}}
allowed_claim_types: {type: array, items: {type: string}}
max_amount:
allOf: [{$ref: '#/components/schemas/Money'}]
nullable: true
MyPayoutChannelList:
type: object
required: [channels]
properties:
channels: {type: array, items: {$ref: '#/components/schemas/MyPayoutChannel'}}
PayoutFieldValue:
type: object
required: [key, type, label, value, display]
properties:
key: {type: string}
type: {type: string}
label: {$ref: '#/components/schemas/PayoutNames'}
sensitivity: {type: string, enum: [masked]}
value:
type: string
nullable: true
description: Always null for a masked field. Use display.
display: {type: string}
PayoutFieldProblem:
type: object
required: [key, reason]
properties:
key: {type: string}
reason: {type: string}
PayoutProfile:
type: object
required: [id, channel_id, channel_key, fields, valid, problems, updated_at]
properties:
id: {type: string, format: uuid}
channel_id: {type: string, format: uuid}
channel_key: {type: string}
fields: {type: array, items: {$ref: '#/components/schemas/PayoutFieldValue'}}
valid:
type: boolean
description: false when the channel's schema changed since these details were saved.
problems: {type: array, items: {$ref: '#/components/schemas/PayoutFieldProblem'}}
updated_at: {type: string, format: date-time}
PayoutProfileList:
type: object
required: [profiles]
properties:
profiles: {type: array, items: {$ref: '#/components/schemas/PayoutProfile'}}
SavePayoutProfileRequest:
type: object
required: [details]
additionalProperties: false
properties:
details:
type: object
additionalProperties: {type: string}
description: Every field's value. A full replace — an omitted field is cleared.
  • Step 1: Write the failing tests (me_handlers_test.go, on Task 6’s harness)
func bankDetails() map[string]any {
return map[string]any{"details": map[string]string{
"payee_name": "Aisyah binti Ali", "bank": "maybank", "account_no": "5140-1234-5678"}}
}
func TestAMemberWithNoPermissionManagesTheirOwnDetails(t *testing.T) {
e := newEnv(t)
bank := e.channelID(t, "bank_transfer")
w := e.do(t, "GET", "/v1/payout/me/channels", e.plainTok, nil)
require.Equal(t, http.StatusOK, w.Code)
require.NotContains(t, w.Body.String(), "credit_account_id")
require.NotContains(t, w.Body.String(), `"key":"crypto"`, "inactive channels are not offered")
require.Contains(t, w.Body.String(), `"choices":[{"key":"maybank"`)
w = e.do(t, "PUT", "/v1/payout/me/profiles/"+bank.String(), e.plainTok, bankDetails())
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
require.Contains(t, w.Body.String(), `"display":"****5678"`)
require.NotContains(t, w.Body.String(), "514012345678")
w = e.do(t, "GET", "/v1/payout/me/profiles", e.plainTok, nil)
require.NotContains(t, w.Body.String(), "514012345678", "no read returns a raw masked value")
require.Contains(t, w.Body.String(), `"value":null`)
require.Equal(t, http.StatusNoContent, e.do(t, "DELETE", "/v1/payout/me/profiles/"+bank.String(), e.plainTok, nil).Code)
require.Equal(t, http.StatusNotFound, e.do(t, "DELETE", "/v1/payout/me/profiles/"+bank.String(), e.plainTok, nil).Code)
}
func TestMyProfilesAreMineAlone(t *testing.T) {
e := newEnv(t)
bank := e.channelID(t, "bank_transfer")
require.Equal(t, http.StatusOK, e.do(t, "PUT", "/v1/payout/me/profiles/"+bank.String(), e.plainTok, bankDetails()).Code)
w := e.do(t, "GET", "/v1/payout/me/profiles", e.plainTok, nil) // PRESENCE for the owner
require.Contains(t, w.Body.String(), `"channel_key":"bank_transfer"`)
w = e.do(t, "GET", "/v1/payout/me/profiles", e.adminTok, nil)
require.Equal(t, `{"profiles":[]}`, strings.TrimSpace(w.Body.String()))
require.Equal(t, http.StatusNotFound, e.do(t, "DELETE", "/v1/payout/me/profiles/"+bank.String(), e.adminTok, nil).Code)
}
func TestSaveMyProfileRefusals(t *testing.T) {
e := newEnv(t)
bank := e.channelID(t, "bank_transfer")
crypto := e.channelID(t, "crypto")
require.Equal(t, http.StatusOK, e.do(t, "PUT", "/v1/payout/me/profiles/"+bank.String(), e.plainTok, bankDetails()).Code)
bad := bankDetails()
bad["details"].(map[string]string)["nric"] = "900101145678"
w := e.do(t, "PUT", "/v1/payout/me/profiles/"+bank.String(), e.plainTok, bad)
require.Equal(t, http.StatusUnprocessableEntity, w.Code)
require.Contains(t, w.Body.String(), "nric: not a field of this channel")
require.Equal(t, http.StatusConflict, e.do(t, "PUT", "/v1/payout/me/profiles/"+crypto.String(), e.plainTok,
map[string]any{"details": map[string]string{}}).Code, "crypto is seeded inactive")
require.Equal(t, http.StatusNotFound, e.do(t, "PUT", "/v1/payout/me/profiles/"+uuid.NewString(), e.plainTok, bankDetails()).Code)
require.Equal(t, http.StatusBadRequest, e.do(t, "PUT", "/v1/payout/me/profiles/"+bank.String(), e.plainTok,
`{"details":{"payee_name":1}}`).Code)
require.Equal(t, http.StatusUnauthorized, e.do(t, "GET", "/v1/payout/me/profiles", "", nil).Code)
}
  • Step 2: Run and watch fail. go test ./internal/modules/payout/ -run 'Mine|Own|MyProfile' -count=1 → FAIL: 404 on /v1/payout/me/....
  • Step 3: Implement me_handlers.go, the four registrations, public_routes.txt, openapi.yaml; make generate.
  • Step 4: Run go test ./internal/modules/payout/ ./internal/app/ ./internal/gen/ -count=1 → PASS, including TestEveryRouteIsAuthorized (the four routes are in [no-permission]) and TestTheOpenAPIDocumentSaysWhatTheRouterEnforces. Sabotage: comment out the GET /v1/payout/me/profiles line in public_routes.txt → TestEveryRouteIsAuthorized red naming it; restore.
  • Step 5: Commit
Terminal window
git add internal/modules/payout api/openapi.yaml internal/gen/api api/public_routes.txt
git commit -m "Let every member keep their own payout details, masked on read"

Task 8: End to end, the roadmap, and the one full run

Section titled “Task 8: End to end, the roadmap, and the one full run”

Files:

  • Create: internal/modules/payout/e2e_test.go
  • Modify: docs/ROADMAP.md

Interfaces:

  • Consumes: every other task, including Task 9 (member.RunDeactivationHooks and the registered purge) — Task 9 is numbered after this one only because it was added in revision; it runs in wave 3 (see Parallel groups). Produces nothing new.

  • Step 1: Write the end-to-end test (Task 6’s harness)

// TestPayoutEndToEnd walks the spec 9 story on a freshly provisioned
// Malaysian org: seven seeded channels, a member saving bank details over
// HTTP, an administrator renaming the bank, and the settlement snapshot
// Plan 5 will store — frozen and sealed, the raw value only through
// OpenSnapshot for the payer, the masked one for everyone else, nothing raw
// in the database or the audit trail — and the purge when the member leaves.
func TestPayoutEndToEnd(t *testing.T) {
e := newEnv(t)
bank := e.channelID(t, "bank_transfer")
require.Equal(t, http.StatusOK, e.do(t, "PUT", "/v1/payout/me/profiles/"+bank.String(), e.plainTok, bankDetails()).Code)
var frozen json.RawMessage
e.acc.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
frozen, err = platformpayout.Snapshot(ctx, tx, bank, e.plain, "expense_claim", money.MustParse("250"), "MYR")
return err
})
var s platformpayout.SettlementSnapshot
require.NoError(t, json.Unmarshal(frozen, &s)) // an approver's read: no key needed
require.Equal(t, "1520", s.Channel.CreditAccountCode)
require.Equal(t, "Maybank", s.Fields[1].Display)
require.Empty(t, s.Fields[2].Value)
require.Equal(t, "****5678", s.Fields[2].Display)
require.NotContains(t, string(frozen), "514012345678")
opened, err := platformpayout.OpenSnapshot(frozen) // the payer's read
require.NoError(t, err)
require.Equal(t, "514012345678", opened.Fields[2].Value)
// Nothing raw in the saved row either (Ruling P15).
var stored string
require.NoError(t, e.acc.H.Admin.QueryRow(context.Background(),
`SELECT to_jsonb(p)::text FROM member_payout_profile p WHERE p.org_id = $1 AND p.member_id = $2`,
e.acc.Org, e.plain).Scan(&stored))
require.NotContains(t, stored, "514012345678")
// An administrator renames the bank; the frozen snapshot still says Maybank.
maybank := e.registryIDByKey(t, "my_banks", "maybank")
require.Equal(t, http.StatusOK, e.do(t, "PATCH", "/v1/payout/registry-entries/"+maybank.String(), e.adminTok,
map[string]any{"names": map[string]string{"en": "Malayan Banking", "ms": "Malayan Banking"}}).Code)
require.NoError(t, json.Unmarshal(frozen, &s))
require.Equal(t, "Maybank", s.Fields[1].Display)
// The GL binding Plan 5 posts against resolves through the contract API.
e.acc.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
ch, err := platformpayout.GetChannel(ctx, tx, bank)
require.Equal(t, s.Channel.CreditAccountID, ch.CreditAccountID)
return err
})
// Every profile change is audited; none of it holds the raw number.
var rows, raw int
require.NoError(t, e.acc.H.Admin.QueryRow(context.Background(), `
SELECT count(*), count(*) FILTER (WHERE coalesce(before::text,'') || coalesce(after::text,'') LIKE '%514012345678%')
FROM audit_log WHERE org_id = $1 AND entity_kind = 'member_payout_profile'`, e.acc.Org).Scan(&rows, &raw))
require.Positive(t, rows) // PRESENCE
require.Zero(t, raw)
// The member leaves: their saved details are purged (Ruling P33); the
// frozen snapshot still opens to what was paid.
e.acc.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `UPDATE organization_member SET active = false WHERE id = $1 AND org_id = $2`,
e.plain, e.acc.Org); err != nil {
return err
}
return member.RunDeactivationHooks(ctx, tx, e.acc.Clock, e.plain)
})
var left int
require.NoError(t, e.acc.H.Admin.QueryRow(context.Background(),
`SELECT count(*) FROM member_payout_profile WHERE org_id = $1 AND member_id = $2`, e.acc.Org, e.plain).Scan(&left))
require.Zero(t, left)
opened, err = platformpayout.OpenSnapshot(frozen)
require.NoError(t, err)
require.Equal(t, "514012345678", opened.Fields[2].Value)
// The audit chain still verifies with the payout rows in it (spec 11A.5).
e.acc.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
brk, err := audit.VerifyChain(ctx, tx)
require.Nil(t, brk)
return err
})
}

(registryIDByKey joins Task 6’s helpers: GET /v1/payout/registry-entries?registry=… and pick by key. e.plain is the plain member’s id, which the harness already holds.)

  • Step 2: Run go test ./internal/modules/payout/ -run EndToEnd -count=1 → PASS.

  • Step 3: Tick the roadmap. In docs/ROADMAP.md, under Plan 6 — Payout channels (§9), add the plan link line and tick both items:

- [ ] **Plan 6 — Payout channels** (§9)
Plan: [`2026-09-21-plan-6-payout-channels.md`](superpowers/plans/2026-09-21-plan-6-payout-channels.md) (9 tasks)
- [x] Channel definitions, `field_schema`, GL binding
- [x] Seven Malaysia seeds; member payout profiles (encrypted at rest, purged on deactivation); settlement snapshot

(The plan’s own box stays unticked until the user merges it.)

  • Step 4: The one full run. go test -p 2 -count=1 ./... → PASS. make lint → clean.

  • Step 5: Commit

Terminal window
git add internal/modules/payout/e2e_test.go docs/ROADMAP.md
git commit -m "Payout channels end to end; tick Plan 6 on the roadmap"

Task 9: Purge a departed member’s payout profiles — the deactivation hook

Section titled “Task 9: Purge a departed member’s payout profiles — the deactivation hook”

Numbered last because it was added in revision; it runs in wave 3, beside Task 5 (see Parallel groups), and Task 8’s end-to-end test uses it.

Why a hook. No code path deactivates a member today: organization_member.active is set to false only by tests’ admin SQL (grep -rn "active = false" internal cmd finds only _test.go files). So Plan 6 cannot call into an existing deactivation path, and must not build one in Plan 2’s area. It adds the smallest seam instead (Ruling P33): a hook registry in two new files of internal/platform/identity/member — no existing file of that package is edited — and registers the payout purge on it. The plan that builds member deactivation calls member.RunDeactivationHooks in the same transaction that sets active = false (Notes for later plans).

Files:

  • Create: internal/platform/identity/member/deactivation.go, internal/platform/identity/member/deactivation_test.go
  • Create: internal/platform/payout/purge.go
  • Test: internal/platform/payout/purge_test.go (package payout_test)

Interfaces:

  • Consumes (Tasks 1, 4): Sealed, openDetails, ErrUnknownKey, ErrSealBroken, GetDefinition, auditView, auditOf, kindProfile, OpenSnapshot; payouttest.New, BankChannel, SeedChannel, SeedProfile, SeedMember. Kernel: audit.Write, clock.Clock, tenancy.

  • Produces:

    package member // internal/platform/identity/member
    type DeactivationHook func(ctx context.Context, tx pgx.Tx, c clock.Clock, memberID uuid.UUID) error
    var ErrDuplicateDeactivationHook, ErrMemberStillActive error
    func OnDeactivate(name string, h DeactivationHook) // from init(); duplicate name panics
    func UnregisterDeactivationHookForTest(name string)
    func RunDeactivationHooks(ctx context.Context, tx pgx.Tx, c clock.Clock, memberID uuid.UUID) error
    package payout
    const HookPurgeProfiles = "payout.profiles"
    func PurgeMemberProfiles(ctx context.Context, tx pgx.Tx, c clock.Clock, memberID uuid.UUID) error
  • Step 1: Write the failing registry test (internal/platform/identity/member/deactivation_test.go, package member_test; no database)

package member_test
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/platform/identity/member"
)
func TestOnDeactivateRefusesADuplicateName(t *testing.T) {
noop := func(context.Context, pgx.Tx, clock.Clock, uuid.UUID) error { return nil }
member.OnDeactivate("test.dup", noop) // PRESENCE: the first registration is accepted
t.Cleanup(func() { member.UnregisterDeactivationHookForTest("test.dup") })
require.PanicsWithError(t, `member: deactivation hook registered twice: "test.dup"`, func() {
member.OnDeactivate("test.dup", noop)
})
}

The database behaviour — refusing a still-active member, running hooks inside the caller’s transaction — is exercised in payout’s purge_test.go below, against the real registered hook.

Run: go test ./internal/platform/identity/member/ -run OnDeactivate -count=1 Expected: FAIL — undefined member.OnDeactivate.

  • Step 2: Write deactivation.go
package member
import (
"context"
"errors"
"fmt"
"maps"
"slices"
"sync"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/kernel/tenancy"
)
// DeactivationHook closes out what a platform package keeps about a member
// who has left — the PDPA 2010 retention principle (spec 17.3). It runs
// inside the transaction that deactivates the member; an error aborts the
// deactivation, so a member is never marked gone with their data kept.
type DeactivationHook func(ctx context.Context, tx pgx.Tx, c clock.Clock, memberID uuid.UUID) error
var (
ErrDuplicateDeactivationHook = errors.New("member: deactivation hook registered twice")
// ErrMemberStillActive: hooks erase data, so they run only for a member
// the caller has already marked inactive in this transaction.
ErrMemberStillActive = errors.New("member: deactivation hooks run only for a member already marked inactive")
)
var (
hookMu sync.Mutex
hooks = map[string]DeactivationHook{}
)
// OnDeactivate registers h under name, from an init() — the RegisterPermission
// convention: process-global, and two packages claiming one name panic at boot.
func OnDeactivate(name string, h DeactivationHook) {
hookMu.Lock()
defer hookMu.Unlock()
if _, exists := hooks[name]; exists {
panic(fmt.Errorf("%w: %q", ErrDuplicateDeactivationHook, name))
}
hooks[name] = h
}
// UnregisterDeactivationHookForTest removes a hook, so a test that registers
// one can run twice in one process (UnregisterPermissionForTest's reason).
// Production code must never call it.
func UnregisterDeactivationHookForTest(name string) {
hookMu.Lock()
defer hookMu.Unlock()
delete(hooks, name)
}
// RunDeactivationHooks runs every registered hook for memberID, in name
// order, on tx. The member deactivation path — whichever plan builds it —
// calls this in the same transaction, after setting
// organization_member.active = false. A hook registers only in binaries that
// link its package; every binary that deactivates must link them all.
func RunDeactivationHooks(ctx context.Context, tx pgx.Tx, c clock.Clock, memberID uuid.UUID) error {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return fmt.Errorf("member: deactivation hooks: %w", tenancy.ErrNoOrg)
}
var active bool
err := tx.QueryRow(ctx,
`SELECT active FROM organization_member WHERE id = $1 AND org_id = $2`, memberID, orgID).Scan(&active)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotAMember
}
if err != nil {
return fmt.Errorf("member: deactivation hooks: %w", err)
}
if active {
return ErrMemberStillActive
}
hookMu.Lock()
registered := maps.Clone(hooks)
hookMu.Unlock()
for _, name := range slices.Sorted(maps.Keys(registered)) {
if err := registered[name](ctx, tx, c, memberID); err != nil {
return fmt.Errorf("member: deactivation hook %s: %w", name, err)
}
}
return nil
}

Run: go test ./internal/platform/identity/member/ -count=1 → PASS. go test ./internal/kernel/layering/ -count=1 → PASS (the new query names org_id).

  • Step 3: Write the failing purge tests (internal/platform/payout/purge_test.go)
func deactivate(e *payouttest.Env, m uuid.UUID) error {
return e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
if _, err := tx.Exec(ctx,
`UPDATE organization_member SET active = false WHERE id = $1 AND org_id = $2`, m, e.Org); err != nil {
return err
}
return member.RunDeactivationHooks(ctx, tx, e.Clock, m)
})
}
func profileCount(t *testing.T, e *payouttest.Env, m uuid.UUID) int {
t.Helper()
var n int
require.NoError(t, e.H.Admin.QueryRow(context.Background(),
`SELECT count(*) FROM member_payout_profile WHERE org_id = $1 AND member_id = $2`, e.Org, m).Scan(&n))
return n
}
// TestDeactivationPurgesTheMembersProfiles is Ruling P33: the departed
// member's profiles go, each with a masked audit row; another member's stay;
// a settlement snapshot taken before still opens to what was paid.
func TestDeactivationPurgesTheMembersProfiles(t *testing.T) {
e := payouttest.New(t)
bank := e.BankChannel(t)
duitnow := e.SeedChannel(t, payouttest.ChannelRow{Key: "duitnow", Name: "DuitNow", CreditAccountID: e.Chart.Bank.ID,
Fields: []payout.Field{{Key: "duitnow_id", Type: payout.FieldText, Required: true, Sensitivity: payout.SensitivityMasked,
Label: payout.Names{"en": "DuitNow ID", "ms": "ID DuitNow"}}}})
p1 := e.SeedProfile(t, e.Member, bank, map[string]string{"payee_name": "Aisyah", "bank": "maybank", "account_no": "514012345678"})
p2 := e.SeedProfile(t, e.Member, duitnow, map[string]string{"duitnow_id": "0123456789"})
bala := e.SeedMember(t, "Bala")
e.SeedProfile(t, bala, bank, map[string]string{"payee_name": "Bala", "bank": "maybank", "account_no": "998877665544"})
var frozen json.RawMessage
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
frozen, err = payout.Snapshot(ctx, tx, bank, e.Member, "expense_claim", money.MustParse("10"), "MYR")
return err
})
require.Equal(t, 2, profileCount(t, e, e.Member)) // PRESENCE
require.NoError(t, deactivate(e, e.Member))
require.Zero(t, profileCount(t, e, e.Member))
require.Equal(t, 1, profileCount(t, e, bala), "another member is unaffected")
rows, err := e.H.Admin.Query(context.Background(), `
SELECT entity_id, coalesce(before::text, ''), after IS NULL FROM audit_log
WHERE org_id = $1 AND entity_kind = 'member_payout_profile' AND action = 'purge'`, e.Org)
require.NoError(t, err)
defer rows.Close()
purged := map[uuid.UUID]string{}
for rows.Next() {
var (
id uuid.UUID
before string
noAfter bool
)
require.NoError(t, rows.Scan(&id, &before, &noAfter))
require.True(t, noAfter, "a purge row has no After")
for _, raw := range []string{"514012345678", "0123456789"} {
require.NotContains(t, before, raw, "purge audit holds a raw value")
}
purged[id] = before
}
require.NoError(t, rows.Err())
require.Len(t, purged, 2) // one row per profile
require.Contains(t, purged[p1], "****5678")
require.Contains(t, purged[p2], "****6789")
// The settlement snapshot is self-contained and untouched (retained 7 years).
opened, err := payout.OpenSnapshot(frozen)
require.NoError(t, err)
require.Equal(t, "514012345678", opened.Fields[2].Value)
// Idempotent: a second run purges and audits nothing.
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
return payout.PurgeMemberProfiles(ctx, tx, e.Clock, e.Member)
})
var n int
require.NoError(t, e.H.Admin.QueryRow(context.Background(), `SELECT count(*) FROM audit_log
WHERE org_id = $1 AND entity_kind = 'member_payout_profile' AND action = 'purge'`, e.Org).Scan(&n))
require.Equal(t, 2, n)
}
// TestHooksRefuseAMemberStillActive: the hook erases data, so it never runs
// for a member the caller has not marked inactive.
func TestHooksRefuseAMemberStillActive(t *testing.T) {
e := payouttest.New(t)
ch := e.BankChannel(t)
e.SeedProfile(t, e.Member, ch, map[string]string{"payee_name": "Aisyah", "bank": "maybank", "account_no": "514012345678"})
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
return member.RunDeactivationHooks(ctx, tx, e.Clock, e.Member)
})
require.ErrorIs(t, err, member.ErrMemberStillActive)
require.Equal(t, 1, profileCount(t, e, e.Member))
require.NoError(t, deactivate(e, e.Member)) // PRESENCE of the accepted case
require.Zero(t, profileCount(t, e, e.Member))
}
// TestPurgeErasesAProfileItCannotRead: erasure must not depend on the key
// (Ruling P33). The row goes; its audit row says unreadable and holds no value.
func TestPurgeErasesAProfileItCannotRead(t *testing.T) {
e := payouttest.New(t)
ch := e.BankChannel(t)
prof := e.SeedProfile(t, e.Member, ch, map[string]string{"payee_name": "Aisyah", "bank": "maybank", "account_no": "514012345678"})
_, err := e.H.Admin.Exec(context.Background(), `
UPDATE member_payout_profile SET details_ct = set_byte(details_ct, 0, get_byte(details_ct, 0) # 1)
WHERE id = $1`, prof)
require.NoError(t, err)
require.NoError(t, deactivate(e, e.Member))
require.Zero(t, profileCount(t, e, e.Member))
var before string
require.NoError(t, e.H.Admin.QueryRow(context.Background(), `SELECT before::text FROM audit_log
WHERE org_id = $1 AND entity_id = $2 AND action = 'purge'`, e.Org, prof).Scan(&before))
require.Contains(t, before, `"unreadable": true`)
require.Contains(t, before, "bank_transfer")
}

(audit_log.before is jsonb; Postgres renders it with a space after the colon, hence "unreadable": true.)

Run: go test ./internal/platform/payout/ -run 'Purge|Deactivation|StillActive' -count=1 Expected: FAIL — undefined payout.PurgeMemberProfiles.

  • Step 4: Write purge.go
package payout
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/kernel/tenancy"
"github.com/mcom/erp/internal/platform/identity/member"
)
// HookPurgeProfiles is the purge's name in member's deactivation registry.
const HookPurgeProfiles = "payout.profiles"
func init() { member.OnDeactivate(HookPurgeProfiles, PurgeMemberProfiles) }
// PurgeMemberProfiles deletes every saved payout profile of a departed
// member (Ruling P33; PDPA 2010 retention principle, spec 17.3) and writes
// one audit row per profile: action "purge", the masked Before, no After.
// It never touches a settlement snapshot — those live on claims, which Plan 5
// keeps for the statutory seven years.
//
// A profile whose ciphertext will not open (ErrUnknownKey, ErrSealBroken) is
// deleted all the same, its Before marked unreadable with no values:
// erasure must not depend on the key. ErrNoKeyring still fails — a binary
// that deactivates without a keyring is misconfigured.
//
// Rows are locked in id order before the first audit.Write (wave-2 §3).
// Idempotent: a member with no profiles purges and audits nothing.
func PurgeMemberProfiles(ctx context.Context, tx pgx.Tx, c clock.Clock, memberID uuid.UUID) error {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return fmt.Errorf("payout: purge profiles: %w", tenancy.ErrNoOrg)
}
if _, err := currentKeyring(); err != nil {
return err
}
rows, err := tx.Query(ctx, `SELECT p.id, p.channel_id, p.details_kid, p.details_nonce, p.details_ct
FROM member_payout_profile p WHERE p.org_id = $1 AND p.member_id = $2 ORDER BY p.id FOR UPDATE`,
orgID, memberID)
if err != nil {
return fmt.Errorf("payout: purge profiles: %w", err)
}
type row struct {
id, channel uuid.UUID
sealed Sealed
}
var all []row
for rows.Next() {
var r row
if err := rows.Scan(&r.id, &r.channel, &r.sealed.KeyID, &r.sealed.Nonce, &r.sealed.Ciphertext); err != nil {
rows.Close()
return fmt.Errorf("payout: purge profiles: %w", err)
}
all = append(all, r)
}
rows.Close()
if err := rows.Err(); err != nil {
return fmt.Errorf("payout: purge profiles: %w", err)
}
befores := make([]auditView, len(all))
ids := make([]uuid.UUID, len(all))
for i, r := range all {
ids[i] = r.id
d, err := GetDefinition(ctx, tx, r.channel) // no lock taken
if err != nil {
return fmt.Errorf("payout: purge profiles: %w", err)
}
details, err := openDetails(orgID, memberID, r.channel, r.sealed)
switch {
case errors.Is(err, ErrUnknownKey) || errors.Is(err, ErrSealBroken):
befores[i] = auditView{MemberID: memberID, ChannelID: d.ID, ChannelKey: d.Key,
Fields: map[string]string{}, Unreadable: true}
continue
case err != nil:
return fmt.Errorf("payout: purge profiles: %w", err)
}
if befores[i], err = auditOf(ctx, tx, d, memberID, details); err != nil {
return err
}
}
if len(ids) == 0 {
return nil
}
if _, err := tx.Exec(ctx,
`DELETE FROM member_payout_profile WHERE org_id = $1 AND id = ANY($2)`, orgID, ids); err != nil {
return fmt.Errorf("payout: purge profiles: %w", err)
}
for i, id := range ids {
if err := audit.Write(ctx, tx, c, audit.Entry{
EntityKind: kindProfile, EntityID: id, Action: "purge", Before: befores[i]}); err != nil {
return fmt.Errorf("payout: purge profiles: %w", err)
}
}
return nil
}
  • Step 5: Run, sabotage, run the guards

Run: go test ./internal/platform/payout/ ./internal/platform/identity/member/ -count=1 -race → PASS. Sabotage (quote each red, restore): (a) delete the init() registration → TestDeactivationPurgesTheMembersProfiles red (2 profiles left); (b) change the purge SELECT’s WHERE to p.org_id = $1 only (keeping $2 bound by adding AND $2::uuid IS NOT NULL) → red on another member is unaffected; (c) make the unreadable branch return err → TestPurgeErasesAProfileItCannotRead red. Run: go test ./internal/kernel/layering/ ./internal/kernel/db/ -count=1 → PASS (every query org-predicated; member_payout_profile still operational, DELETE held).

  • Step 6: Commit
Terminal window
git add internal/platform/identity/member/deactivation.go internal/platform/identity/member/deactivation_test.go \
internal/platform/payout/purge.go internal/platform/payout/purge_test.go
git commit -m "Purge a departed member's payout profiles through a member deactivation hook"

Tasks in one wave touch disjoint files and may run in parallel; waves run in order. “Needs” lists the tasks whose output a task consumes.

Wave Tasks Needs Why they are disjoint
1 1 migration + contract API Plan 4 merged in the base The only task touching the migration, the retention list, sqlc models, and payout/{doc,errors,pgerr,names,fields,registryread,seal,channel,snapshot}.go, payouttest/. Alone: every later task builds on it. Plan 5 may start consuming GetChannel/Snapshot/OpenSnapshot/ResealSnapshot after this wave.
2 2 definitions · 3 registries · 4 profiles 1 2: define.go, define_test.go. 3: registry.go, registry_test.go. 4: profile.go, profile_test.go. No shared file; each uses only Task 1’s helpers and payouttest’s admin inserts.
3 5 seeds + steps 90–91 · 9 purge on deactivation 5: 2, 3 · 9: 4 5: seed.go, steps.go, their tests, countrypack/my/payout{,_test}.go, plus step-key expectations in consumer tests — the only task of the wave touching provisioning. 9: payout/purge{,_test}.go and the two new files identity/member/deactivation{,_test}.go — no file shared with 5.
4 6 module + composition + admin routes + payout key 2, 3, 5 Touches every shared file (app.go, cmd/*, constants.yaml, openapi.yaml, kernel/config/config.go, .env.example, docs/DEPLOY_NOTES.md); runs alone.
5 7 self-service routes 4, 6 me_handlers.go, routes.go, openapi.yaml, public_routes.txt; alone (shares openapi.yaml and routes.go with 6).
6 8 end to end + roadmap + full run all (1–7 and 9) Alone.

Critical path: 1 → 2 → 5 → 6 → 7 → 8 (Tasks 3 and 4 run beside 2; Task 9 beside 5). Task 9 is numbered last only because it was added in revision.


Binding hand-offs recorded by this plan’s rulings. Each later plan’s author must honour them or amend the ruling with the user.

  • Plan 8 (petty cash), Ruling P6 — user decision. Plan 8 must post a petty-cash settlement’s credit to the paying holder’s float account, never to the petty_cash channel’s CreditAccountID (the seeded placeholder, MY 1510 Cash in hand).
  • Plan 5 (expense claims), Ruling P6/P29 — user decision. Plan 5’s payout posting rule must defer to Plan 8 whenever the snapshot’s settlement_behaviour is petty_cash: it never credits Channel.CreditAccountID for such a claim. (How Plan 5 behaves before Plan 8 lands is Plan 5’s ruling.)
  • Plan 5, Rulings P31–P32. Store payout.Snapshot’s JSON exactly as returned (masked values sealed). Render display to approvers by unmarshalling into payout.SettlementSnapshot (no key needed); call payout.OpenSnapshot only on the payer’s path; map ErrNoKeyring/ErrUnknownKey/ErrSealBroken to 500. Plan 5 owns the operator reseal command (all orgs: payout.ResealProfiles + payout.ResealSnapshot over its claims); until it ships, a previous payout key is never dropped. Plan 5’s test fixtures install a keyring (payouttest.New does; otherwise payouttest.UseKeyring).
  • Whichever plan builds member deactivation, Ruling P33. Call member.RunDeactivationHooks(ctx, tx, c, memberID) in the transaction that sets organization_member.active = false, after the update; an error aborts the deactivation. The binary doing it must link internal/platform/payout and install the payout keyring.

Checked against the contract and the spec before execution.

Spec coverage.

  • §9.2 channel definition (every column of the spec’s payout_channel, label_i18n as name + translations per P8) → Task 1 (table), Task 2 (writes). Closed settlement_behaviour → DB CHECK (1), Go set (1), OpenAPI enum (6), pinned by TestSettlementBehavioursMatchTheCheck and TestSettlementBehaviourEnumMatchesThePlatform. credit_account_id binding “without code changes” → Task 2’s Wise USD test.
  • §9.3 field_schema: all seven types (Task 1, P9), sensitivity: masked driving masking (Task 1 Mask/display, Task 4 views, Task 7 wire), registry_ref generalization (Tasks 1, 3), labels snapshotted (Task 1 SnapshotField.Label, P8).
  • §9.4 storage on the claim — Plan 5’s columns; Plan 6 supplies the snapshot and the two generated-column inputs (channel.key, account_tail) (Task 1, P17). Mitigation 1 “validate in one place” → ValidateDetails. Mitigation 2 (DB CHECK on required keys) and 3 (generated columns) sit on Plan 5’s claim table; account_tail and channel.key stay plaintext in the sealed snapshot for them (P31). The profile table’s details are ciphertext, so their object shape is checked in Go (openDetails), with member_payout_profile_{kid_format,nonce_length,ct_length} in the DB.
  • §9.5 seven Malaysia seeds with the three disambiguations (DuitNow no bank; e-wallet provider not “bank name”; crypto network registry) → Task 5, asserted field by field.
  • §9.6 member payout profiles → Task 4 (+ Task 7 HTTP); eligibility (allowed_claim_types, allowed_currencies, max_amount) → Task 1 eligible, Task 2 shape rules, P18/P19; bulk payment files → not Phase 1 (spec says so).
  • §9.7 rules 1–4 → Task 2 CheckDefinition (1–3), Task 3’s ErrRegistryInUse (3 kept true after publish, P25), REVOKE DELETE + P7 (4); readiness surfacing → Task 5 WarnChannelInvalid.
  • §8.4 petty-cash settlement → P6 (constraints and placeholder account); the float debit itself is Plan 8’s.
  • §10.1–10.2 “a module never hardcodes an account code” / “resolves to a bank journal” → seeds name journal types (Task 5), P5.
  • §11A / §17.3 personal data (PDPA 2010): minimize (unknown keys refused, ValidateDetails), encrypt at rest (Task 1 seal.go, P15/P30–P32: round trip, tamper, wrong key, no plaintext in the row or snapshot, rotation — seal_internal_test.go, TestStoredDetailsAreCiphertext, TestSnapshotRefusesATamperedOrMovedProfile, TestSnapshotSurvivesKeyRotation, TestProfilesSurviveKeyRotationAndReseal), mask (every API read, Tasks 4, 7), audit every change without raw values (Task 4 TestAuditNeverHoldsARawSensitiveValue, Task 8), purge a departed member’s details with snapshots untouched (Task 9, P33), Malaysia residency (no external call).
  • §20.1.4 registry primitive rules: case-insensitive keys (lower-case CHECK), retire-never-delete, labels as translation rows → Tasks 1, 3. §20.1.5 errors → mapErr (Task 6). §20.2 provisioning idempotent and API-repairable → Task 5 (Check/Run, second-run test); readiness endpoint already exists.
  • Wave-2 contract: exported API exact (Task 1); migration 00090; steps 90–91; permission payout.channel.manage; paths /v1/payout/…; §3 lessons — named constraints (guard + sabotage), pinned search path (no functions added), no SET CONSTRAINTS, MapError at commit (not applicable, P29), one org per transaction (every query tenant-predicated), lock order (channel → profile; registry entries by id; Snapshot lock-free, tested), test hygiene (timeouts, cleanup, presence-first, sabotage steps).

User decisions applied (revision). (1) Encryption at rest → P15 replaced, P30–P32 added; Tasks 1 (seal.go, migration columns, sealed Snapshot, OpenSnapshot, ResealSnapshot), 4 (sealed profiles, ResealProfiles), 6 (PAYOUT_DETAILS_KEY config, .env.example, deploy note, cmd/api keyring), 8; contract §1 revised. (2) Purge on deactivation → P33, new Task 9 (hook registry in new identity/member files; no deactivation path exists yet); retention list keeps member_payout_profile in operational, the only payout table with DELETE. (3) Petty cash → P6 amended; Notes for later plans binds Plans 8 and 5. (4) Crypto seeded inactive → P23 confirmed, unchanged. (5) Seed lists → P34; comments at each list in Task 5.

Placeholder scan. No “TBD”/“TODO”/“similar to Task N”. Three places name existing code to copy rather than repeating it: internal/modules/accounting/http.go’s request plumbing, its permissionLoader, and the chart_handlers_test.go harness — real files an implementer can open, as Plan 4 did with orgstructure’s. provisioning.Report/StepState field names and member.NewPermissionSet were read from the real files.

Type consistency. Names (Task 1) is the type of Field.Label/Help, ChannelInput.Names, RegistryEntryInput.Names, RegistrySeed/ChannelSeed.Names, SnapshotChannel.Names, and the module’s request bodies. InvalidChannelError is produced by Tasks 2 and 3 and mapped in Task 6. scanDefinition(row, extra...) is used by Tasks 1 (Snapshot), 2, and 4 (ListProfiles with three extras). kindChannel/kindRegistryEntry strings in the platform equal the module’s constants ("payout_channel", "payout_registry_entry"). FieldView.Value is *string and is nil exactly when Field.Masked(). SettlementBehaviours backs the DB, Go and OpenAPI pins. Sealed{KeyID, Nonce, Ciphertext} is the one ciphertext shape for profile rows (details_kid/_nonce/_ct) and snapshot fields (SnapshotField.Sealed); profileAAD/snapshotFieldAAD are used by every seal and open of their kind (Tasks 1, 4, 9). auditView (Task 4, with Unreadable) is reused by Task 9. PurgeMemberProfiles has member.DeactivationHook’s exact signature. payouttest.New sets Env.Key (id test-1) used by Tasks 1 and 4’s rotation tests; the module harness installs its own keyring (Task 6). config.KeyedSecret maps field-for-field onto payout.Key in cmd/api.

Needs the user’s decision: none open — the five recorded questions were decided (above). Still unverified by design: the seed lists (P34), to be confirmed before any paying customer.