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:
docs/superpowers/specs/2026-09-21-phase1-wave2-contracts.md— binding: the exported API (§1), ownership, migration range00090–00109, steps90–99, permission prefixpayout.*, paths/v1/payout/…(§2), the Plan 4 review lessons (§3).docs/superpowers/specs/2026-09-21-phase1-wave1-contracts.md— §3 (shared files are append-only) and §5 (standing constraints).docs/superpowers/specs/2026-09-20-erp-architecture-design.md— §9 (payout channels, in full), §4 (tenancy, audit), §8.4 (petty-cash settlement), §10.1–10.2 (chart, journals), §11A (statutory; member bank details are personal data), §17.3 (erasure), §20 (registry primitive, error taxonomy, i18n, provisioning).
Global Constraints
Section titled “Global Constraints”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/…, andinternal/platform/countrypack/my/payout.go+payout_test.go(payout data only). Three user-directed exceptions, each append-only:internal/kernel/config/config.gogainsPayoutDetailsKeysFromEnv(+ a newpayoutkeys_test.go),.env.exampleanddocs/DEPLOY_NOTES.mdgain the payout-key entries (Task 6, Rulings P30, P32); andinternal/platform/identity/member/deactivation.go+deactivation_test.goare new files in Plan 2’s package holding the deactivation-hook registry (Task 9, Ruling P33) — no existing file ofidentity/memberis edited. Never editinternal/platform/posting,internal/platform/accounting/…,internal/platform/countrypack/*.goorcountrypack/my/my.go. Never import anything of Plan 5 (expenses,documents) or Plan 3 (workflow). - Migrations
00090–00109only (this plan uses00090). Provisioning step orders90–99only (this plan uses 90, 91). Permissionspayout.*only. OpenAPI paths/payout/…only (served under/v1). - Shared files are append-only (wave-1 contract §3):
constants.yamlpermissions list,api/openapi.yaml,api/public_routes.txt([no-permission]section),internal/app/app.go,internal/app/routes_test.go(composedModules), the imports incmd/api,cmd/migrate,cmd/orgadmin, theirwiring_test.golists,internal/kernel/db/retention_test.golists,docs/ROADMAP.md(tick only Plan 6 lines). Generated files (internal/gen/…,internal/kernel/vocab/vocab.gen.go,packages/contracts/vocab.ts) are regenerated withmake generate, never hand-merged. - The contract API is exact (wave-2 contract §1).
payout.Channelhas exactly the nine fields listed there, in that order, with those types.GetChannel(ctx, tx, id) (Channel, error)andSnapshot(ctx, tx, channelID, memberID, claimType, amount, currency) (json.RawMessage, error)keep those signatures;OpenSnapshot(raw json.RawMessage) (SettlementSnapshot, error)andResealSnapshot(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 ininternal/platform/payout/errors.go. - Tenant isolation, both layers. Every new table has
org_id UUID NOT NULL,ENABLEandFORCE ROW LEVEL SECURITY, and a policy namedorg_isolationwithUSINGandWITH CHECK. Every tenant query in Go carries an explicitorg_id = $nfor every org-scoped table it names, and every INSERT namesorg_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
00090has an explicitCONSTRAINT <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.TestPayoutConstraintsAreNamedenforces it. - No database functions or triggers in this plan. (If one is ever added it must
SET search_path = pg_catalog, public, pg_tempand schema-qualify its tables —TestEveryFunctionPinsItsSearchPath.) NoSET CONSTRAINTSin non-test Go. - Every new table appears in exactly one list in
internal/kernel/db/retention_test.go.payout_channelandpayout_registry_entryareamendable(DELETE revoked);member_payout_profileisoperational— 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.Writeon the samepgx.Tx: channel create/update, registry-entry create/update, profile save/delete/reseal/purge. An audit row never contains a raw value of amaskedfield (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 asSealed. Nothing falls back to plaintext: with no keyring installed every seal and open fails withErrNoKeyring. Decryption happens only insideinternal/platform/payout, and a raw masked value leaves the package only throughOpenSnapshot. 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_channelbeforemember_payout_profile; registry entries inidorder; a member’s profiles (purge) and an org’s profiles (reseal) inidorder. This plan adds no advisory-lock class.GetChannelandSnapshottake no locks and write nothing, so a caller may use them anywhere in its transaction, including after its ownaudit.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 (Moneyschema). Neverfloat64. - Personal data (§11A, §17.3; Malaysia PDPA 2010). Member payout details are personal data: collect only what a channel’s
field_schemadeclares, refuse unknown keys, encrypt them at rest (security principle), masksensitivity: maskedfields 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
enandms;zhis 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.WithTimeoutand clean up int.Cleanup. During development run only the packages you changed (go test ./internal/platform/payout/... -count=1).make generateafter any migration, OpenAPI orconstants.yamlchange. One fullgo test -p 2 -count=1 ./...in Task 8 only. - Git commits end with:
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rulings
Section titled “Rulings”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/payouttestfixtures), one moduleinternal/modules/payout, Malaysia data incountrypack/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 generatestill refreshesinternal/gen/sqlc/models.go— Plan 4 R2’s pattern, and the tenant-predicate guard reads Go literals — none. - P3 Ruling:
payout_channelrows are per organization (org_id NOT NULL), not global registry rows —credit_account_idmust bind to the org’s own chart, and erp_app cannot insertorg_id IS NULLrows throughWITH CHECK— a platform-wide change to a seeded channel must be applied org by org. - P4 Ruling:
registry_reflists (banks, e-wallet providers, crypto networks) live in a new per-org tablepayout_registry_entry (registry, key, name, …)seeded at provisioning, not inexpense_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_idof an active journal of typebankorcash; 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’scredit_account_idresolves 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_cashchannel credits through acashjournal, declares noaccount_noorregistry_reffield, 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’sCreditAccountIDis 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’sCreditAccountID(cash in hand), and Plan 5’s payout posting rule must defer to Plan 8 whenever the snapshot’ssettlement_behaviourispetty_cash— it never creditsCreditAccountIDfor such a claim. Recorded in Notes for later plans. - P7 Ruling: A channel’s
keyandsettlement_behaviour, and a registry entry’sregistryandkey, 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 (
namecolumn in the company’s record locale +translationrowsen/ms/zh); field labels and help live inline infield_schemaas{en, ms, zh?}maps,enandmsrequired — 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 —
textsingle line ≤max(default 140);addressmulti-line ≤max(default 500);phonespaces/hyphens/brackets stripped then^\+?[0-9]{7,15}$;account_nospaces/hyphens stripped, upper-cased,^[0-9A-Z]{4,34}$;selectone of its option keys;registry_refthe key of an active entry of the named registry;filea 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_nofield must declaresensitivity: 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:
Maskshows****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;
PUTreplaces the whole profile, so editing one field means re-entering the others — minimization (§11A): a raw masked value leaves this package only throughOpenSnapshot, 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_logis 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_profileisoperational(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]stringmarshalled to JSON and sealed with AES-256-GCM (random 96-bit nonce per seal, the key id stored beside it) intodetails_kid,details_nonce,details_ct; there is no plaintextdetailscolumn. 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 insideinternal/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 marksmasked, 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:
Snapshotrequires 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’sdisplay, labels, registry names, channel names in every locale, the credit account id and code, andaccount_tail, with an unmasked field’svaluein 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 rendersdisplayto approvers and callsOpenSnapshotonly for the payer. - P18 Ruling: A channel with
max_amountmust 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_typesare 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 CHECKpayout_channel_behaviour_valid, andpayout.SettlementBehaviours, pinned together by tests — notconstants.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]inapi/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", …)fromcountrypack/my/payout.go, not by extending Plan 4’scountrypack.Packinterface — the contract gives Plan 6 “payout data only” inmy/and none ofcountrypack/*.go— country data sits in two registries. - P23 Ruling (confirmed by the user): The seven Malaysia channels credit through the default
bankjournal, exceptcashandpetty_cash(defaultcashjournal);cryptois seeded inactive;requires_settlement_proofis true for bank transfer, DuitNow, e-wallet, cheque and crypto, false for cash and petty cash;cheque_nois 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:
TestPayoutConstraintsAreNamedcovers 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 inidorder 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_invalidfor 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/workerdoes not linkmodules/payout— the worker neither provisions nor posts payout data, andTestEveryBinaryLinksTheSameModulesbinds 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.MapErrorhas no caller here; Plan 5’s payout rule creditsChannel.CreditAccountID— except forpetty_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:base64secretwith the secret exactly 32 bytes (theJWT_KEYSshape, so the id travels with the key), plus optionalPAYOUT_DETAILS_KEY_PREVIOUSin the same shape, open-only; both are read byconfig.PayoutDetailsKeysFromEnv, which refuses a missing active key, a malformed or wrong-length key, the.env.exampleplaceholder, and a previous key sharing the active key’s id. It is required bycmd/api, the one binary that constructs the payout module and serves profiles; it is its own function, not part ofconfig.Load, becausecmd/workeralso callsLoadand does not link payout (P28).cmd/apibuildspayout.NewKeyringand callspayout.InstallKeyringonce at boot; the keyring is process-wide because the contract fixesSnapshot’s signature (no key parameter) — a binary that never installs one getsErrNoKeyringfrom every seal and open, never plaintext —cmd/migrateandcmd/orgadminlink 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) intoSnapshotField.SealedwithValueempty; unmasked values,display, labels andaccount_tailstay plaintext. Plan 5 stores the JSON exactly as returned; an approver-facing readerjson.Unmarshals it intoSettlementSnapshotand rendersDisplaywith no key; the payer’s path callspayout.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, andSnapshot’s signature stays the contract’s — reading a masked value requires the key in the reading process (a report run in a keyless binary seesdisplayonly, 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, oneresealaudit row each carrying key ids only), andResealSnapshotdoes 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 onepurgeaudit row per profile — maskedBefore, noAfter; a profile whose ciphertext will not open (ErrUnknownKey/ErrSealBroken) is still deleted, itsBeforerecordingunreadable: trueand no values (erasure must not depend on the key;ErrNoKeyringstill fails, as a misconfigured binary). Settlement snapshots on claims are never touched. No code path deactivates a member today (only tests setorganization_member.active = false), so Plan 6 adds, in new files inidentity/member, a hook registry —member.OnDeactivate(name, hook)frominit()andmember.RunDeactivationHooks(ctx, tx, c, memberID), which refuses a member still active and runs hooks in name order — and registerspayout.profilesfrominternal/platform/payout’sinit(). The plan that builds member deactivation must callmember.RunDeactivationHooksin the transaction that setsactive = 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.goand here — the same treatment as Plan 4’s SST rates — none; an org can add or retire entries itself.
File Structure
Section titled “File Structure”Migration (db/migrations/)
Section titled “Migration (db/migrations/)”| 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. |
New Go packages and files
Section titled “New Go packages and files”| 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. |
Modified
Section titled “Modified”| 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; regenerateinternal/gen/sqlc/models.go - Test:
internal/platform/payout/{fields_test,channel_test,snapshot_test,schema_test}.go(packagepayout_test);internal/platform/payout/seal_internal_test.go(packagepayout—seal/open/resealare unexported; it must not importpayouttest, which importspayout)
Interfaces:
-
Consumes:
accountingtest.New/SeedChart/SeedJournals/SeedMember(Plan 4),chart.CreateJournal,tenancy.OrgFromContext,money.Amount. -
Produces:
package payoutconst (BehaviourExternal = "external"; BehaviourPettyCash = "petty_cash")var SettlementBehaviours = []string{BehaviourExternal, BehaviourPettyCash}type Channel struct { // exactly the contract'sID uuid.UUID; Key string; SettlementBehaviour string; CreditAccountID uuid.UUIDAllowedCurrencies []string; AllowedClaimTypes []string; MaxAmount *money.AmountRequiresSettlementProof 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]stringtype Option struct { Key string; Label Names }type Field struct { Key, Type string; Required bool; Max int; Sensitivity stringLabel, Help Names; Options []Option; Registry string }func (f Field) Masked() boolfunc ValidateSchema(fields []Field) error // *InvalidSchemaErrorfunc ValidateDetails(ctx context.Context, tx pgx.Tx, fields []Field,details map[string]string) (map[string]string, error) // normalized; *InvalidDetailsErrorfunc Mask(v string) stringtype RegistryEntry struct { ID uuid.UUID; Registry, Key, Name string; IsActive bool; SortOrder int }const SnapshotVersion = 1type SettlementSnapshot struct { Version int; Channel SnapshotChannel; MemberID uuid.UUID; ProfileID *uuid.UUIDClaimType string; Amount money.Amount; Currency string; Fields []SnapshotField; AccountTail *string }type SnapshotChannel struct { ID uuid.UUID; Key, Name string; Names Names; SettlementBehaviour stringCreditAccountID uuid.UUID; CreditAccountCode string; RequiresSettlementProof bool }type SnapshotField struct { Key, Type string; Label Names; Sensitivity, Registry stringValue string // plaintext for an unmasked field; "" in stored JSON for a masked oneSealed *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 Sealedfunc 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() stringfunc InstallKeyring(k *Keyring) *Keyring // returns the one it replacedtype 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) []bytefunc 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 errortype IneligibleError struct { Rule, Detail string } // Rule: RuleAmount|RuleCurrency|RuleClaimType|RuleMaxAmounttype 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 validfunc recordName(n Names, locale string) stringfunc writeNames(ctx context.Context, tx pgx.Tx, orgID uuid.UUID, kind string, id uuid.UUID, n Names) errorfunc 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) errorpackage payouttesttype Env struct { *accountingtest.Env; Chart accountingtest.Chart; Journals accountingtest.JournalsCashJournal chart.Journal; Member uuid.UUIDKey payout.Key } // the active key New installed, id "test-1"func New(t *testing.T) *Env // installs a fresh keyring for the testfunc NewKey(t *testing.T, id string) payout.Key // 32 random bytesfunc UseKeyring(t *testing.T, active payout.Key, previous *payout.Key) *payout.Keyring // restored at Cleanupfunc (e *Env) Other(t *testing.T, name string) *Envtype ChannelRow struct { Key, Name, Behaviour string; CreditAccountID uuid.UUID; Fields []payout.FieldCurrencies, ClaimTypes []string; MaxAmount string; RequiresProof, Inactive bool }func (e *Env) SeedChannel(t *testing.T, r ChannelRow) uuid.UUIDfunc (e *Env) SeedRegistryEntry(t *testing.T, registry, key, name string) uuid.UUIDfunc (e *Env) SeedProfile(t *testing.T, member, channel uuid.UUID, details map[string]string) uuid.UUID // sealed via SealDetailsfunc 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 DownDROP 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 toamendable:
// 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.goandpgerr.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, packagepayout_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: packagepayouthas no Go files / undefined names. -
Step 7: Write
registryread.goandfields.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, packagepayout)
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, likeaccountingtest; its SQL is scanned by the tenant-predicate guard, so every INSERT namesorg_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: undefinedpayout.GetChannel,payout.Snapshot,payout.OpenSnapshot,payout.ResealSnapshot,payout.Channel. -
Step 13: Write
doc.go,channel.goandsnapshot.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 payoutpackage 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, changeCONSTRAINT payout_channel_key_uniq UNIQUE (org_id, key)to a bareUNIQUE (org_id, key)→TestPayoutConstraintsAreNamedred namingpayout_channel.payout_channel_org_id_key_key. (b) RemoveFORCE ROW LEVEL SECURITYfrommember_payout_profile→TestEveryTableIsTenantIsolatedred. (c) Changemember_payout_profile_channel_fktoFOREIGN KEY (channel_id) REFERENCES payout_channel (id)→TestEveryTenantForeignKeyIsCompositeOnOrgIDred. (d) DeleteAND c.org_id = $2fromGetDefinition→TestEveryTenantQueryCarriesAnExplicitOrgPredicatered. (e) Add'crypto'topayout_channel_behaviour_valid→TestSettlementBehavioursMatchTheCheckred. (f) InSnapshot, setsf.Value = vfor masked fields too →TestSnapshotFreezesChannelAndProfilered withno masked value in plaintext in the stored snapshot. (g) Inseal.go, passnilinstead ofaadtogcm.Seal→TestOpenRefusesATamperedSeal/…(the other-row case) andTestSnapshotRefusesATamperedOrMovedProfilered. -
Step 16: Commit
git add db/migrations/00090_payout.sql internal/kernel/db/retention_test.go internal/gen/sqlc internal/platform/payoutgit 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 []FieldAllowedCurrencies, AllowedClaimTypes []string; MaxAmount *money.AmountRequiresSettlementProof bool; SortOrder int; Inactive bool}type ChannelPatch struct {Names *Names; CreditAccountID *uuid.UUID; Fields *[]FieldAllowedCurrencies, AllowedClaimTypes *[]string; MaxAmount **money.AmountRequiresSettlementProof *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, keyfunc 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: undefinedpayout.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. Thengo test ./internal/kernel/layering/ -count=1→ PASS. -
Step 5: Commit
git add internal/platform/payout/define.go internal/platform/payout/define_test.gogit 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, keyInput 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: undefinedpayout.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: dropFOR UPDATEfrom the lock statement, runTestConcurrentRetirementsCannotEmptyAUsedRegistrya few times with-count=5until it reports[2 0], quote it, restore. -
Step 5: Commit
git add internal/platform/payout/registry.go internal/platform/payout/registry_test.gogit 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 stringValue *string // nil for a masked field — never populated from a raw masked valueDisplay string}type ProfileView struct {ID, ChannelID uuid.UUID; ChannelKey string; Fields []FieldViewValid bool; Problems []FieldProblem // against the channel's CURRENT schemaUpdatedAt 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) errorfunc ListProfiles(ctx context.Context, tx pgx.Tx, memberID uuid.UUID) ([]ProfileView, error) // by channel sort_order, keyfunc ResealProfiles(ctx context.Context, tx pgx.Tx, c clock.Clock) (int, error) // Ruling P32const kindProfile = "member_payout_profile"// unexported, used by Task 9:type auditView struct { MemberID, ChannelID uuid.UUID; ChannelKey string; Fields map[string]stringChanged []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: undefinedpayout.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: makeauditOfwritevalinstead ofdisp→TestAuditNeverHoldsARawSensitiveValuered withaudit row create holds a raw account number; restore. Sabotage for P15: inSaveProfile, after sealing, setsealed.Ciphertext, _ = json.Marshal(values)(the plaintext JSON stored as the “ciphertext”) →TestStoredDetailsAreCiphertextred withciphertext holds "514012345678"; restore. Sabotage for P32: dropAND p.details_kid <> $2fromResealProfiles→TestProfilesSurviveKeyRotationAndResealred (2resealed, want1); restore. Rungo test ./internal/kernel/layering/ -count=1→ PASS. -
Step 5: Commit
git add internal/platform/payout/profile.go internal/platform/payout/profile_test.gogit 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 stringCreditJournalType string /* chart.JournalBank | chart.JournalCash */; Fields []FieldRequiresSettlementProof 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 duplicatefunc SeedsFor(country string) (CountrySeeds, bool)func ValidateSeeds(s CountrySeeds) errorfunc SeedRegistries(ctx context.Context, tx pgx.Tx, c clock.Clock, s CountrySeeds) errorfunc SeedChannels(ctx context.Context, tx pgx.Tx, c clock.Clock, s CountrySeeds) errorconst (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: undefinedpayout.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
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.gogit add -u # the step-key expectations updated in Step 6git 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(appendpayout.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.*, includingKey,NewKeyring,InstallKeyring;payouttest.NewKey,UseKeyringfor the harness);httpx.Protected,member.RegisterPermission,member.Permissions,i18n.Load. - Produces:
package payout // internal/modules/payout; imports the platform as platformpayoutconst 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) errorpackage config // internal/kernel/configtype 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 detailfunc 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.manageto the end ofpermissions:. -
internal/app/app.go:Payout payout.DepsonDeps(import"github.com/mcom/erp/internal/modules/payout");payout.Register(api, deps.Payout)afteraccounting.Register. -
internal/app/routes_test.go: append"payout": {payout.PermissionChannelManage},tocomposedModules. -
cmd/api/main.go: import the module and passPayout: 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". Theirwiring_test.go: append"github.com/mcom/erp/internal/modules/payout",towantModulesand"payout.channel.manage", // modules/payouttowantPermissions.cmd/workeris 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; thenmake generate. - Step 4: Compose (
app.go,routes_test.go,cmd/api,cmd/migrate,cmd/orgadmin, the twowiring_test.go). - Step 5: Write the failing configuration test (
internal/kernel/config/payoutkeys_test.go, packageconfig_test; reusesrealKeyandenvExampleValuesfromplaceholders_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.exampleand 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:
# 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 — accountnumbers, DuitNow and e-wallet IDs, payee names — and the masked values inside everyclaim's settlement snapshot (Malaysia PDPA 2010, security principle). Format`kid:base64secret`, the secret exactly 32 bytes:
```shPAYOUT_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 withthe `.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 accountnumber; that holds only while the key is not stored beside the dump. **Losing the keyloses every saved payout detail** (members re-enter them) **and every masked value inevery 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 itunreadable (`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 deletionis audited with masked values only (Ruling P33). Claim snapshots are not touched. Nodeactivation 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
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.mdgit 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’shandlers,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/channelsGET /v1/payout/me/profilesPUT /v1/payout/me/profiles/:channel_idDELETE /v1/payout/me/profiles/:channel_idapi/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, includingTestEveryRouteIsAuthorized(the four routes are in[no-permission]) andTestTheOpenAPIDocumentSaysWhatTheRouterEnforces. Sabotage: comment out theGET /v1/payout/me/profilesline inpublic_routes.txt→TestEveryRouteIsAuthorizedred naming it; restore. - Step 5: Commit
git add internal/modules/payout api/openapi.yaml internal/gen/api api/public_routes.txtgit 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.RunDeactivationHooksand 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
git add internal/modules/payout/e2e_test.go docs/ROADMAP.mdgit 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(packagepayout_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/membertype DeactivationHook func(ctx context.Context, tx pgx.Tx, c clock.Clock, memberID uuid.UUID) errorvar ErrDuplicateDeactivationHook, ErrMemberStillActive errorfunc OnDeactivate(name string, h DeactivationHook) // from init(); duplicate name panicsfunc UnregisterDeactivationHookForTest(name string)func RunDeactivationHooks(ctx context.Context, tx pgx.Tx, c clock.Clock, memberID uuid.UUID) errorpackage payoutconst 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, packagemember_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
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.gogit commit -m "Purge a departed member's payout profiles through a member deactivation hook"Parallel groups
Section titled “Parallel groups”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.
Notes for later plans
Section titled “Notes for later plans”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_cashchannel’sCreditAccountID(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_behaviourispetty_cash: it never creditsChannel.CreditAccountIDfor 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). Renderdisplayto approvers by unmarshalling intopayout.SettlementSnapshot(no key needed); callpayout.OpenSnapshotonly on the payer’s path; mapErrNoKeyring/ErrUnknownKey/ErrSealBrokento 500. Plan 5 owns the operator reseal command (all orgs:payout.ResealProfiles+payout.ResealSnapshotover its claims); until it ships, a previous payout key is never dropped. Plan 5’s test fixtures install a keyring (payouttest.Newdoes; otherwisepayouttest.UseKeyring). - Whichever plan builds member deactivation, Ruling P33. Call
member.RunDeactivationHooks(ctx, tx, c, memberID)in the transaction that setsorganization_member.active = false, after the update; an error aborts the deactivation. The binary doing it must linkinternal/platform/payoutand install the payout keyring.
Self-Review Notes
Section titled “Self-Review Notes”Checked against the contract and the spec before execution.
Spec coverage.
- §9.2 channel definition (every column of the spec’s
payout_channel,label_i18nasname+ translations per P8) → Task 1 (table), Task 2 (writes). Closedsettlement_behaviour→ DB CHECK (1), Go set (1), OpenAPI enum (6), pinned byTestSettlementBehavioursMatchTheCheckandTestSettlementBehaviourEnumMatchesThePlatform.credit_account_idbinding “without code changes” → Task 2’s Wise USD test. - §9.3
field_schema: all seven types (Task 1, P9),sensitivity: maskeddriving masking (Task 1Mask/display, Task 4 views, Task 7 wire),registry_refgeneralization (Tasks 1, 3), labels snapshotted (Task 1SnapshotField.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_tailandchannel.keystay plaintext in the sealed snapshot for them (P31). The profile table’s details are ciphertext, so their object shape is checked in Go (openDetails), withmember_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 1eligible, 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’sErrRegistryInUse(3 kept true after publish, P25),REVOKE DELETE+ P7 (4); readiness surfacing → Task 5WarnChannelInvalid. - §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 1seal.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 4TestAuditNeverHoldsARawSensitiveValue, 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; permissionpayout.channel.manage; paths/v1/payout/…; §3 lessons — named constraints (guard + sabotage), pinned search path (no functions added), noSET 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.