Skip to content

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 Origin header is never trusted. It is never reflected into Access-Control-Allow-Origin and never widened to *. An absent Origin, 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 ref code (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 active and its Stripe status is active and 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 JSON null) → 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_code and 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 is 503 (never a false 201). commission_rate is 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.

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) still 302s the visitor to /signup but with no ref and 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)+day key (a UNIQUE + INSERT IGNORE); the endpoint is also per-IP rate-limited. The dashboard shows clicks_30d. Click rows are not used for attribution or payout, and cascade-delete with the partner on erasure.
  • aig_ref cookie (attribution window): scoped to the registrable domain (Domain=.myra.eu, derived from the app origin — not host-only), HttpOnly, Secure, SameSite=Lax, 60-day Max-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 signup POST on ai-api-admin.myra.eu. POST /admin/auth/signup/request reads it as a fallback when body.ref is absent (explicit ref wins), 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 JSON null/object/array → 400 otp_required and 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 of vat_registered | kleinunternehmer | non_eu; anything else → 400. null clears it.
  • bic — validated only when present: 8 or 11 chars, AAAAAA (6 letters) + NN + optional NNN [A-Z0-9]; otherwise 400. null clears 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 JSON null clears the field; an absent key leaves it unchanged (three distinct paths).
  • self_billing_consent — a boolean: true stamps self_billing_consent_at = now (the partner accepts the self-billing credit-note / Gutschrift T&C); false withdraws it; a non-boolean → 400.
  • payout_min_cents / payout_hold are operator-only — a self-service PUT can 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.