Partner dashboard
The partner / affiliate surface lets an external affiliate sign in and see the seats and
expected monthly payout attributed to its referral code. A partner is a distinct trust tier from
admins and tenants: it is served by the /partner/* surface on the admin-API host (ai-api-admin.myra.eu), authenticates with its
own cookie (aig_partner) and JWT (purpose="partner", never the aig_admin session), and can reach
only its own aggregates — never another partner's data, and never any referred customer's identity.
Partners may be admin-provisioned (see Admin: partners) or self-register through the open, instant self-signup below — a self-registered partner is active immediately (referral code + payout eligibility), with no human approval gate. Email ownership is still proven via the partner email-OTP before any session is granted (anti-squatting).
Cross-origin (CORS) policy
The partner SPA is served from the app origin (https://ai.myra.eu in production,
https://ai-beta.myra.eu on beta) and calls the /partner/* surface on the admin-API origin
(ai-api-admin*.myra.eu), so every partner request is cross-origin and credentialed (the
aig_partner cookie). Each response — the OPTIONS preflight, every success, and every error (401 /
403 / 404 / 429 / 503) — carries Access-Control-Allow-Origin: <the app origin> together with
Access-Control-Allow-Credentials: true.
- The allowed origin is a single, fixed, server-configured value — the one SPA front door of the
deployment, resolved once per request from the platform front-door setting (
AIG_FRONTEND_URL, falling back to the admin SPA origin). Beta and production each resolve to their own app origin automatically; there is no per-surface override to misconfigure. - The request
Originheader is never trusted. It is never reflected intoAccess-Control-Allow-Originand never widened to*. An absentOrigin, a mismatched one (e.g.https://evil.example), and a malformed/garbage one all produce the same header — the configured app origin — so a browser whose page origin is not the configured SPA origin has its credentialed request rejected by the browser's own CORS check. A*wildcard is never emitted on this credentialed surface (it is forbidden with credentials and would be a cross-site read). - Fail-closed: if the front door is unconfigured the response still carries a fixed origin (the platform default), never the caller's origin, so a misconfiguration can only block partners on the wrong host — never expose their data to an attacker page.
Attribution & payout model
- Attribution is stamped on the whole referred tenant, for its lifetime, by the partner id
(never the code string, so a code rename never breaks past attributions). It is set once — at
self-serve signup from the
refcode (see Authentication) or by an admin crediting a sales deal — and never mutated afterward. - Commission defaults to 10% of the referred tenant's monthly recurring revenue (per-partner overridable). The expected payout is computed from the active paying subscriptions below and paid out through the SEPA credit-transfer and Gutschrift export flow described later in this chapter.
- Payout base = active paying subscriptions only: a subscription counts toward the expected payout
only when it is
activeand its Stripe status isactiveand it is not cancel-at-period-end. Trials and cancel-at-period-end accounts count as attributed seats (grace-period accounts count as attributed tenants only, and are excluded from the active-seat tile) but contribute €0 to payout. Currency is EUR; a non-EUR, tiered, unpriced, or unresolvable subscription is skipped from the payout (never silently counted as €0) and logged for ops.
Sign-in (partner front-door)
Email one-time-passcode, at ai-api-admin.myra.eu/partner/auth/*. The address is canonicalised; unknown/suspended
partners are indistinguishable from known ones (anti-enumeration).
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/partner/auth/otp/request |
none | Body { "email": "…" }. Always 200 {message} for a well-formed email ("if that email is a registered partner, a code has been sent"); 400 invalid_email for a malformed address; 429 otp_request_cap over the per-address request cap; 503 on a backend fault (never a false "code sent"). |
POST |
/partner/auth/otp/verify |
none | Body { "email": "…", "code": "123456" }. 200 {partner} + sets the aig_partner cookie on success; 401 invalid or expired code; 429 otp_attempt_cap/otp_failure_cap; 400 when email/code absent or malformed. The partner is re-checked active at issuance. |
POST |
/partner/auth/logout |
aig_partner |
Clears the cookie. |
Self-signup (open/instant)
A prospective affiliate registers itself at the public partner front-door. The partner is created
active at once (no approval queue); the session is granted only after the emailed OTP is verified
through the existing /partner/auth/otp/verify — so the flow is
signup → (emailed code) → verify → dashboard.
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/partner/auth/signup |
none | Body { "name": "…", "email": "…", "referral_code"?: "…" }. Creates an active partner and emails an OTP, then 201 { message, email, referral_code }. Issues no session cookie — the client then verifies the emailed code at /partner/auth/otp/verify. |
- Accepted/rejected input (trust boundary, fail-closed — the client is never the authz boundary):
name— a non-empty string ≤ 200 bytes with no control characters. Absent / non-string / empty / too-long / control-char →400 invalid_name.email— a single-@, ASCII, whitespace-free address ≤ 254 bytes, canonicalised before use. Absent or malformed →400 invalid_email. A second signup with an existing address →409 duplicate_email(one partner per email).referral_code— optional. Absent (omitted or JSONnull) → a unique code is auto-generated. Present → it must normalise to^[A-Z0-9-]{4,64}$(trimmed, upper-cased); a present-but-malformed code (wrong type, charset or length) →400 invalid_referral_codeand is never silently replaced by an auto-generated one (absent and malformed do not share a path). A code already in use →409 duplicate_code(one code per partner).- Abuse controls (still enforced under the open policy): a per-IP fixed-window signup cap
(
429 signup_rate_cap) and a per-IP cap on/otp/request; a transient backend fault is503(never a false201).commission_rateis not client-settable at signup — it defaults to the platform default (10%).
Static OTP. A partner may be assigned a fixed login code by an admin (see
Admin: partners below) — the partner-login mirror of the admin-account static OTP. A partner
carrying a static code does not receive an emailed one: /otp/request returns the same generic
200 but mints nothing (so the fixed code is never invalidated by the newest-code purge), and
/otp/verify accepts the fixed code via the shared verifier. Clearing the code (below) restores the
ordinary email-OTP flow. Used for demo/test partners and automation where a mailbox round-trip isn't
practical; the plaintext is never stored (only its sha256).
Accepted/rejected input (trust boundary): email must be a single-@, ASCII, whitespace-free
address ≤ 254 bytes; anything else → 400 invalid_email. code must be a string or number (6 digits);
a JSON null, object, or array → 400 and is never counted as a wrong guess.
Referral link & click tracking
The link a partner shares is a tracked redirect (shown as referral_url on the dashboard):
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/r/<code> |
none (public) | Logs a click, sets the aig_ref attribution cookie, then 302s to <app>/signup?ref=<code>. |
The link is the clean app-origin https://<app-host>/r/<code> (e.g. https://ai.myra.eu/r/ACME),
served same-origin on every environment's SPA front door (ai.myra.eu, ai-int.myra.eu,
ai-beta.myra.eu) — not the ai-api-admin API host. referral_url on the dashboard is built from the
app origin so partners always share the short form.
- Accepted/rejected input (trust boundary):
<code>is normalised to^[A-Z0-9-]{4,64}$and resolved to an active partner before any write or cookie. An unknown / malformed / suspended code (or a DB fault) still302s the visitor to/signupbut with norefand no cookie (fail-closed — no wrong attribution). The redirect host is fixed (the app origin); only the normalised code is interpolated, so it is not an open redirect and cannot inject headers. - Click counting: each hit is deduplicated to one per (partner, visitor, day) via a coarse,
non-identifying
sha256(client_ip)+daykey (aUNIQUE+INSERT IGNORE); the endpoint is also per-IP rate-limited. The dashboard showsclicks_30d. Click rows are not used for attribution or payout, and cascade-delete with the partner on erasure. aig_refcookie (attribution window): scoped to the registrable domain (Domain=.myra.eu, derived from the app origin — not host-only),HttpOnly,Secure,SameSite=Lax, 60-dayMax-Age, carrying the normalised code. The domain scope lets the cookie — set by/r/on the app host (e.g.ai.myra.eu) — reach the cross-origin same-site signupPOSTonai-api-admin.myra.eu.POST /admin/auth/signup/requestreads it as a fallback whenbody.refis absent (explicitrefwins), through the same normalise + active-partner resolution — so a signup within the window is still credited even when?ref=isn't on the signup URL, and a forged/garbage cookie can never attribute or inject (fail-closed). On a dev/single host with no registrable parent (localhost/IP) the cookie is host-only. This assumes the app host and the admin API share one registrable domain.
Dashboard
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/partner/v1/summary |
aig_partner |
The partner's own aggregate (counts + expected payout). |
GET |
/partner/v1/referrals |
aig_partner |
The partner's own referred tenants, in full detail. |
/summary response:
{
"referral_code": "ACME",
"referral_url": "https://ai.myra.eu/r/ACME",
"partner_name": "Acme GmbH",
"attributed_tenant_count": 12,
"total_active_seats": 87,
"expected_monthly_payout_eur": 214.50,
"commission_rate": 0.10,
"clicks_30d": 340
}
/referrals response — one row per referred tenant, in full:
{ "referrals": [ {
"company": "acme-gmbh",
"admin_name": "Jane Doe", "admin_email": "jane@acme.example",
"status": "active",
"seats": 12,
"usage": { "requests": 48210, "tokens": 91200000 },
"last_active_at": 1790000000,
"monthly_contribution_eur": 21.40,
"referred_at": 1788000000
} ] }
status ∈ active|trialing|grace|canceling|canceled|none. usage is a bounded 30-day rollup of the
billing ledger (request_log_legs). admin_name/admin_email may be null when no admin resolves.
Isolation (full transparency, still hard-scoped): the v1 "aggregates-only / anonymized"
rule is superseded by CEO decision — a partner now sees its own referred tenants' identity
(company + admin name/email, for direct contact), status, seats, and usage, so it can coordinate. The
scope is still absolute: the partner id is taken only from the verified session; no path/query/body
accepts a partner or tenant id, so a partner can never see another partner's tenants. Exposing
referred-customer PII to the affiliate is deliberate — the legal footing (affiliate agreement + T&C/DPA)
is tracked internally. Data is read-through (the partner surface stores no copy), so a referred
customer's erasure removes it from the partner view automatically. A 503 catalog_unavailable is
returned if the price catalog is temporarily cold (never a misleading €0).
Referral code (self-service, AGF-3062)
A partner can change its own referral code from the dashboard. The old code is kept as a permanent
alias (CEO decision): old /r/OLD and ?ref=OLD links keep attributing to the partner forever, so
a code change never loses conversions. The new code becomes primary. Attribution is keyed on the
partner id, never the code string, so a change never touches past attributions.
Partner-session scoped: the partner id comes only from the verified session (ngx.ctx.partner),
never a path/query/body — a partner can only ever read or change its own code.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/partner/v1/referral-code |
aig_partner |
Current code + retired alias codes + cooldown state. |
PATCH |
/partner/v1/referral-code |
aig_partner |
Change the code. Body { code }. |
GET response:
{
"referral_code": "ACME",
"alias_codes": ["ACME2025", "OLDACME"],
"code_change_available_at": 1793000000,
"cooldown_days": 30
}
alias_codes are the partner's retired codes (all still attributing). code_change_available_at is
the unix-seconds time the next self-service change is allowed, or null when changeable now.
PATCH { code } — accepted shape: after trim + uppercase, the code must match [A-Z0-9-] and be
4–64 characters. On success (200) the current code is retired into partner_code_alias, the new
code becomes primary, code_changed_at is stamped, and a partner.code_changed audit row is written
(old/new code only — codes are public vanity strings, never PII). Response { ok, referral_code,
code_changed_at }.
Rejected (each a DISTINCT outcome — the control is not defeated by sending garbage):
| Case | Status | code |
|---|---|---|
no code field (absent / JSON null) |
400 |
code_required |
| malformed (wrong charset / length / non-string) | 400 |
invalid |
| the code you already have | 400 |
unchanged |
| collides any live code OR any retired alias (incl. your own past codes — a code maps to exactly one place, forever) | 409 |
code_taken |
| within the 30-day cooldown | 429 |
cooldown (+ available_at, unix seconds) |
| too many attempts (per-partner rate limit) | 429 |
rate_limited |
| session partner no longer live | 404 |
not_found |
Cross-table uniqueness (a code lives in exactly one place across partner.referral_code ∪
partner_code_alias.code) is enforced at every write path — self-service change, admin rename,
and admin create — so a code can never resolve to two partners. The resolver checks the live code
first (it wins), then the alias table.
Admin note: an admin rename (PATCH /admin/v1/partners/{id} with a new referral_code) also
retires the old code to an alias (old links keep attributing) and is rejected if the new code
collides a live code or any alias (409 duplicate_code / duplicate_alias). Admin changes have no
cooldown and do not consume or reset the partner's self-service cooldown clock. A multi-field
admin PATCH applies status, name/email/commission, and the code change as independent steps (the
irreversible code change runs last, so a failing name/email never mints an alias), so a partial
apply is possible on a mixed failure — retry is safe. Erasing a partner frees its codes (including
aliases) for future reuse — the partner is GDPR-gone and attribution is by id.
Bank & tax details (self-service, AGF-2828)
The partner enters and maintains its own payout bank & tax detail from the dashboard. Partner-session
scoped: the partner id + on-file email come only from the verified session (ngx.ctx.partner),
never a path/query/body — so a partner can only ever see or change its own detail. The IBAN is
financial PII: it is stored in full server-side but only ever returned MASKED to last-4; the full
value never leaves the server on this surface (only the payout generator reads it in full).
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/partner/v1/bank |
aig_partner |
Own bank detail. IBAN is iban_last4 + has_iban only — never the full IBAN. Also returns bic, account_holder_name, country, vat_status, vat_id, self_billing_consent_at, payout_min_cents, payout_hold, iban_changed_at. An unset bank is { "has_iban": false, "payout_hold": false }. |
PUT |
/partner/v1/bank |
aig_partner |
Set the non-IBAN tax/bank fields. Body any of { account_holder_name, bic, country, vat_status, vat_id, self_billing_consent }. No OTP (not the account-takeover vector). |
POST |
/partner/v1/bank/iban-otp |
aig_partner |
Mail a fresh one-time code to the on-file address (the change confirmation). 200 {message}; 429 otp_request_cap over the per-address cap; 503 unavailable on a backend fault. |
PUT |
/partner/v1/bank/iban |
aig_partner |
Change the payout IBAN. Body { iban, otp }. Requires the fresh OTP; on an actual change it stamps iban_changed_at, audits (old-masked → new-masked + ip) and emails the on-file address. 200 {ok, changed, iban_last4}. |
Anti-account-takeover controls (mandatory). Changing the IBAN — the money-drain vector — requires a
fresh emailed OTP (partner_iban_change purpose, isolated from the login code), sends an email
alert to the on-file address on any change, stamps iban_changed_at (which drives a 7-day
cooling-off hold on the next payout, enforced at export), and writes a full partner.iban_changed
audit row (old/new last-4 only, ts, ip). A stolen session alone cannot drain funds: the attacker
also needs the on-file mailbox, and the owner is alerted.
Accepted / rejected input (trust boundary, invariant 11 — a malformed value is rejected, never degraded to "no value"):
iban— normalised (whitespace stripped, upper-cased) then validated:[A-Z0-9]only, 15–34 chars, exact per-country length, ISO 7064 mod-97 checksum, and the country must be in the SEPA allowlist. Any failure →400 invalid_iban; the IBAN is never stored. A malformed IBAN is validated before the OTP is consumed (a typo does not burn a code). Re-submitting the IBAN already on file is a no-op (no re-stamp, no notification, so a replay cannot reset the cooling-off).otp— a string or number; a JSONnull/object/array →400 otp_requiredand is never counted as a wrong guess. A wrong/expired code →401 otp_invalid; caps →429 otp_attempt_cap/otp_failure_cap.vat_status— one ofvat_registered|kleinunternehmer|non_eu; anything else →400.nullclears it.bic— validated only when present: 8 or 11 chars,AAAAAA(6 letters) +NN+ optionalNNN[A-Z0-9]; otherwise400.nullclears it.country— a 2-letter code;vat_id—[A-Z0-9]{4,20}(whitespace stripped, upper-cased);account_holder_name— 1–255 chars, no control bytes. A malformed value →400; a JSONnullclears the field; an absent key leaves it unchanged (three distinct paths).self_billing_consent— a boolean:truestampsself_billing_consent_at = now(the partner accepts the self-billing credit-note / Gutschrift T&C);falsewithdraws it; a non-boolean →400.payout_min_cents/payout_holdare operator-only — a self-servicePUTcan never set its own payout floor or lift its own hold (they are silently not accepted on this surface; see admin CRUD).
Error responses carry a stable code (AGF-3064). Every error on the partner surfaces returns
{ "error": "<short text>", "code": "<stable code>" }. The code is the contract the dashboard keys
on — it maps each code to a localized, friendly message and never renders the raw error text
(a closed set keyed by code; an unknown/absent code degrades to a generic message). Raw validation
detail (e.g. an IBAN "checksum failed" reason) is log-only and is scrubbed from the client body.
Codes in use: invalid_iban, invalid (non-IBAN field validation), otp_required, otp_invalid,
otp_attempt_cap, otp_failure_cap, otp_request_cap, not_found, invalid_body (unparseable request
body), unavailable (transient backend fault), internal, unauthenticated. A wrong IBAN-change OTP
is a 401 otp_invalid with a code, so the client shows the "invalid code" message in place rather
than mistaking it for a session expiry and redirecting to login.
Admin: partners
Platform-admin only (PARTNERS_MANAGE). Every mutation is audited (the audit record carries ids and
changed-field names only — never the partner email/name; the IBAN only as a masked last-4).
| Method | Path | Description |
|---|---|---|
GET |
/admin/v1/partners |
List non-deleted partners. |
POST |
/admin/v1/partners |
Create. Body { name, email, referral_code, commission_rate? }. 409 duplicate_code/duplicate_email; 400 invalid on a bad field. |
PATCH |
/admin/v1/partners/{id} |
Update status (active/suspended) and/or name/email/referral_code/commission_rate. 404 if no such live partner. |
PUT |
/admin/v1/partners/{id}/static-otp |
Set or clear the partner's static OTP. Body { "code": "…" } — a 4–32 char fixed login code (hashed server-side; plaintext never stored), or { "code": null } (or an absent code) to clear it. A present-but-malformed (wrong-length) code is 400 invalid, never a silent clear. 404 if no such live partner. Audited as partner.static_otp_set / partner.static_otp_cleared (never the code). |
POST |
/admin/v1/tenants/{id}/credit-partner |
Credit a tenant to a partner (sales deal). Body { partner_id }. Set-once and atomic: 409 already_credited if the tenant already has a referrer; 400 partner_inactive for a suspended/unknown partner; 404 tenant_not_found. |
GET |
/admin/v1/partners/{id}/bank |
View a partner's bank detail (IBAN masked to last-4). An unset bank is { "has_iban": false, "payout_hold": false }. |
PUT |
/admin/v1/partners/{id}/bank |
Set/update the bank detail + payout config. Body any of { iban, bic, account_holder_name, country, vat_status, vat_id, self_billing_consent, payout_min_cents, payout_hold }. The IBAN goes through the same validator (mod-97 + SEPA) and stamps iban_changed_at; payout_min_cents overrides the platform default (10000 cents = €100, enforced at export); payout_hold suspends payouts. 400 invalid/invalid_iban on a bad field; 404 if no such partner. |
DELETE |
/admin/v1/partners/{id}/bank |
Clear a partner's entire bank detail (IBAN + all tax/config). 404 if there was none. |
DELETE |
/admin/v1/partners/{id}/erase |
GDPR Art. 17 erasure — see below. |
Admin: partner payout export (SEPA + Gutschrift)
Lane C of epic AGF-2827 (AGF-2830) turns the payout ledger
into money actually paid: a bank-ready ISO 20022 pain.001.001.09 SEPA credit-transfer batch plus, per
partner, a self-billing Gutschrift (§14 Abs. 2 UStG) structured e-invoice. The gateway is the system
of record and produces the files; a human approves before any file exists; Finance submits the
file through the existing EBICS channel (the gateway never talks to the bank). All routes are
platform-admin only (PARTNERS_MANAGE); the money-mutating routes are additionally refused while
impersonating. Every approve/generate/export/pay is written to audit_log. Money is integer EUR cents.
A period is a run key YYYY-MM (the closed calendar month, Europe/Berlin). A malformed or absent
period is rejected 400 invalid_period — never coerced.
| Method | Path | Description |
|---|---|---|
GET |
/admin/v1/partner-payouts |
List generated batches (newest period first). |
GET |
/admin/v1/partner-payouts/review?period=YYYY-MM |
Per-partner eligibility for the run: status (ready/approved/exported/skipped), net/VAT/gross cents, VAT treatment, masked IBAN last-4, and a skip_reason for each skipped partner (no_consent/no_iban/payout_hold/dispute_hold/iban_cooling_off/below_threshold/no_balance/vat_*). period defaults to the just-closed month. |
POST |
/admin/v1/partner-payouts/approve |
Money gate. Body { period, partner_id }. Moves the partner's mature accruals → approved. 409 with code = the skip reason if the partner is not eligible; 400 invalid for a bad partner_id. |
POST |
/admin/v1/partner-payouts/unapprove |
Revert approved → mature (reversible before a batch exists). Body { period, partner_id }. |
POST |
/admin/v1/partner-payouts/generate |
Produce the pain.001 + one Gutschrift per approved, payable partner, atomically (gapless legal number allocation, rows → exported, batch recorded). Body { period }. Idempotent per period (a re-call returns the existing batch, byte-identical msg_id + XML). 409 empty if nothing is approved; 400 config if the debtor IBAN / buyer VAT id are unset. |
POST |
/admin/v1/partner-payouts/mark-exported |
Operator confirms the file was handed to the bank. Body { period }. generated → exported; must precede mark-paid. 404 if no batch. |
POST |
/admin/v1/partner-payouts/mark-paid |
Mark one partner paid (per-partner — a single rejected IBAN never marks the whole batch). Body { batch_id, partner_id }. Requires the batch to be exported (409 batch_not_exported); 409 no_exported_rows if that partner has nothing exported (already paid). |
GET |
/admin/v1/partner-payouts/pain001?period=YYYY-MM |
Download the SEPA pain.001 XML (attachment). 404 if no batch. |
GET |
/admin/v1/partner-payouts/batch/{id}/gutschriften |
List a batch's Gutschriften (metadata: number, VAT treatment, net/VAT/gross). |
GET |
/admin/v1/partner-payouts/gutschrift/{id} |
Download one Gutschrift e-invoice (UBL/XRechnung) XML (attachment). |
Accepted shape / what is rejected (trust boundary). period must match ^\d{4}-\d{2}$ with a real
month — else 400 invalid_period. partner_id/batch_id must be non-empty strings — else 400 invalid.
generate refuses (never emits a broken file) when: the debtor IBAN is unset or fails mod-97/SEPA
validation, the buyer VAT id is unset, the SEPA control sum ≠ Σ transaction amounts, or a partner's VAT
treatment cannot be determined (a missing/garbage vat_status/country, or an EU reverse-charge partner
with no VAT id → that partner is skipped with a reason, never issued a wrong document). A partner
whose destination IBAN changed < 7 days ago is held (iban_cooling_off) — the anti-ATO
cooling-off. A partner with an open dispute, on hold, without consent/IBAN, or below the payout threshold
is skipped-with-reason and carries forward to a later run.
Debtor / buyer configuration (platform settings, DB-backed, never env): partner_payout_debtor_iban
(required), partner_payout_debtor_bic, partner_payout_debtor_name, and the Gutschrift buyer party
partner_payout_buyer_name/_address/_city/_zip/_country/_vat_id (buyer VAT id required). Full
runbook: Partner payout export runbook.
Data protection
The partner row holds an email (PII), and the 1:1 partner_bank row holds financial PII
(IBAN, BIC, account-holder name, VAT ID). Erasure (DELETE /admin/v1/partners/{id}/erase) runs in one
transaction: it purges the partner's partner_login one-time-passcode rows, nulls every attribution
that references the partner (tenant.referred_by_partner_id/referred_at and
signup_intent.referred_by_partner_id), deletes the partner_bank row (all bank/tax PII), deletes
the partner row, and writes a counts-only partner.erased audit tombstone (no email/name/IBAN). The
partner_bank FK is also ON DELETE CASCADE, so the bank PII can never outlive the partner even by
accident. Suspending a partner (status='suspended') is a soft action that preserves history; only
erasure removes the PII.
Retention split (tax vs. PII). Once the payout ledger ships (epic AGF-2827, Lane B), executed
payouts are GoBD tax records retained ~10 years under the pseudonymous partner_id — the money
facts are kept while the contact + bank PII is erased. Erasing a partner therefore removes who they
are and where the money went, but not the audited fact that a commission was paid. See
Data retention.