Auth guard: any trusted-backend actor — admin keys, benefit-provider human/service users, and platform human/service users. App users (JWT) are forbidden.

Overview

A sponsor is a donor entity — an individual person or an organisation (trust, CSR fund, society, etc.) — that contributes funds toward matching driver insurance premiums. Each registered sponsor has an external “normal account” on the ledger provider (PBA today) that holds its balance. Subsidies later flow out of the sponsor’s normal account into individual users’ purpose-bound (PB) accounts via separate transfer operations. This module covers registration only — the contribution / transfer ledger is a separate concern.
  • Generic discriminant: SponsorKind = INDIVIDUAL | ORGANISATION. Per-kind detail payloads live in a typed JSONB union.
  • Three-stage lifecycle: PENDING → ACTIVE → INACTIVE. Sponsors are born PENDING and transition to ACTIVE when their PBA normal-account is provisioned and linked.
  • Account row, not column: a sponsor’s PBA provider_account_id lives as a row in the accounts table (holder = sponsor:<sponsor_id> + account_type = SPONSOR). The sponsors row itself carries identity and lifecycle only.
  • Recovery path: POST /sponsors/{id}/sync re-links a sponsor whose create succeeded at PBA but failed locally afterwards (idempotent on ACTIVE).

Lifecycle

ACTIVE → PENDING and INACTIVE → anywhere are rejected with 409 SPE_1404 (SponsorNotActivatable).

POST flow

Two writes are split for safety:
  1. Sponsor row INSERT — single statement, outside any transaction. Gated by partial-unique indexes on name and slug.
  2. PBA CreateNormalAccount — network call. Held outside any DB transaction so the connection pool doesn’t stall on PBA latency.
  3. Atomic local link — inside transaction_async: INSERT the accounts row (holder = sponsor:<id>, account_type = SPONSOR, external_account_id = pba_resp.provider_account_id) and flip sponsor.status = ACTIVE.
If step 2 fails the sponsor stays PENDING with no accounts row — recover via POST /sponsors/{id}/sync. If step 3 fails after step 2 succeeded, the same /sync path recovers (it re-attempts the atomic link).

Status enum


The sponsor_details JSON discriminator (sponsor_kind key) must match the top-level sponsor_kind field. A mismatch on create is a 400 SPE_1405 (validation error); a mismatch on update (sponsor_details variant vs the stored sponsor_kind) is a 400 SPE_1402.

Auth Guards


Endpoints

POST /sponsors

Register a new sponsor + optionally provision its external normal account inline.

GET /sponsors

Paginated list. Filter by statuses and/or sponsor_kinds (comma-separated, multi-select).

POST /sponsors/{id}/sync

Recovery path. Link a PENDING sponsor to its existing PBA normal account.

GET /sponsors/{id}

Fetch a single sponsor by UUID.

PATCH /sponsors/{id}

Update mutable fields. slug and sponsor_kind are immutable.

DELETE /sponsors/{id}

Soft-delete (status → INACTIVE).

GET /sponsors/{id}/balance

Current balance on the sponsor’s normal account at PBA.

GET /sponsors/{id}/transactions

Paginated ledger from PBA. Supports date-range filters.

POST /sponsors/{id}/deposit

Add funds to the sponsor’s normal account.

POST /sponsors/{id}/contribute

Sponsor → user HSA transfer for a specific insurance_policy_id.

Fields


PII / Secret Policy


Examples

The sponsor’s PBA provider_account_id is not on this response — it lives on the linked accounts row (holder = sponsor:<id>, account_type = SPONSOR). Fetch via GET /accounts?holder=sponsor:<id> (admin) if needed.

Reconciliation flow

If PBA’s CreateNormalAccount succeeds but the local link transaction fails (rare), the sponsor row stays in PENDING with no accounts row. PBA has the account; we just couldn’t write the linkage locally. Recovery — POST /sponsors/{id}/sync:
  1. Operator pulls the provider_account_id from PBA’s admin surface.
  2. POST /sponsors/{id}/sync with { "external_account_id": "<pba_account_id>" }.
  3. Server verifies PBA’s holder_id matches our sponsor_id, then atomically INSERTs the accounts row and flips the sponsor to ACTIVE.
  4. Idempotent — if the sponsor is already ACTIVE, returns the current row as 200 without any PBA call.
Once PBA exposes a “find normal account by holder_id” endpoint, the request body becomes empty and the server resolves the id internally (see TODO(pba-find-by-holder) in core::sponsor::sync_sponsor).

Error Codes