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-1407.

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

ValueMeaning
PENDINGLocal row created; linked accounts row not yet present.
ACTIVEPBA normal-account provisioned and linked via the accounts table (holder = sponsor:<id>, account_type = SPONSOR). Ready to receive contributions.
INACTIVESoft-deleted via DELETE /sponsors/{id}. Frees name and slug for reuse.

KindDetails payload
INDIVIDUALIndividualSponsorDetailsfirst_name (required); last_name, email, phone (optional)
ORGANISATIONOrganisationSponsorDetailslegal_name (required); registration_number, contact (optional). contact is a ContactDetails block: email, phone, person_first_name, person_last_name (all optional)
The sponsor_details JSON discriminator (sponsor_kind key) must match the top-level sponsor_kind field — mismatches return 400 SPE-1404.

Auth Guards

EndpointTrusted-backendNotes
POST /sponsorsPBA CreateNormalAccount runs inline; on success the linking accounts row is inserted and the sponsor flips to ACTIVE in a single transaction. PBA failure leaves the row PENDING — recover via POST /{id}/sync.
GET /sponsorsFilter by statuses and/or sponsor_kinds (comma-separated, multi-select; same shape as GET /users)
GET /sponsors/{sponsor_id}
PATCH /sponsors/{sponsor_id}name, pan, address, sponsor_details, status (state-machine validated)
POST /sponsors/{sponsor_id}/syncRecovery — link a PENDING sponsor whose create succeeded at PBA but failed locally afterwards. Idempotent on ACTIVE.
DELETE /sponsors/{sponsor_id}Soft-delete (status → inactive). Idempotent.
GET /sponsors/{sponsor_id}/balancePBA proxy. Sponsor must be ACTIVE.
GET /sponsors/{sponsor_id}/transactionsPBA proxy. Pagination + date range. Sponsor must be ACTIVE.
POST /sponsors/{sponsor_id}/depositAdd funds to sponsor’s normal account. Sponsor must be ACTIVE.
POST /sponsors/{sponsor_id}/contributeSponsor → user HSA transfer. Targets a specific insurance_policy_id; policy must be in APPROVED status.

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

FieldTypeNotes
idUUID v7Server-generated. Passed to PBA as holder_id.
sponsor_kindINDIVIDUAL | ORGANISATIONImmutable after creation.
namestringRequired. Human-presentable. Partial-unique across active sponsors.
slugstringRequired. Lowercase [a-z0-9-]{2,40}. Immutable. Partial-unique across active sponsors.
statusenumPENDING (default) / ACTIVE / INACTIVE.
panstring | nullIndian PAN format [A-Z]{5}[0-9]{4}[A-Z]. Optional.
addressAddressDetails | nullOptional.
sponsor_detailstyped JSONB unionPer-kind identity payload. Variant must match sponsor_kind.
bank_details (POST only)BankDetails | nullOptional. Pass-through to PBA’s origin_*; never persisted.
created_at, last_modified_atISO 8601 datetimeUTC.

PII / Secret Policy

FieldTreatment
bank_details.account_number (POST only)Secret<String> end-to-end; never logged, never persisted.
bank_details.ifsc_codePlain string; not PII.
panPlain string; persisted in the pan column.
addressPlain JSONB.
sponsor_details.email / .phone / .contact_*Plain JSONB. Format validation deferred to a separate PR.

Examples

curl -X POST http://localhost:8080/sponsors \
  -H 'Authorization: Bearer <trusted-backend-token>' \
  -H 'Content-Type: application/json' \
  -d '{
    "sponsor_kind": "INDIVIDUAL",
    "name": "Asha Rao",
    "slug": "asha-rao",
    "sponsor_details": {
      "sponsor_kind": "INDIVIDUAL",
      "first_name": "Asha",
      "last_name": "Rao",
      "email": "asha@example.com"
    },
    "pan": "ABCPR1234X",
    "bank_details": {
      "ifsc_code": "HDFC0000123",
      "account_number": "50100123456789"
    }
  }'
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

CodeHTTPDescription
SPE-1400500Internal server error
SPE-1401404Sponsor not found
SPE-1402400sponsor_details variant does not match stored sponsor_kind
SPE-1403409Sponsor is already linked to an external account
SPE-1404409Sponsor cannot be activated from its current state (e.g. INACTIVE on /sync)
SPE-1405400Validation error (empty name, invalid slug, malformed PAN, half-supplied bank details, etc.)
SPE-1406409Uniqueness violation (slug collision)
SPE-1407500External account provisioning failed (PBA call errored)
SPE-1408409Sponsor is not active (no linked accounts row); cannot proxy to PBA
SPE-1409500PBA reports the external account is unknown
SPE-1410500PBA deposit call failed
SPE-1411409Insurance policy is not in APPROVED status; contribution rejected
SPE-1412409User has no active HSA account
SPE-1413500PBA transfer call failed
SPE-1414500PBA read (balance / transactions) failed
SPE-1415404Insurance policy not found