Customers — profile, addresses, documents

The signed-in customer surface. Every endpoint here requires a customer session: authorization: Bearer <supabase jwt> (minted by the passwordless flow — see auth.md — or a client-side supabase password sign-in) plus the x-client-id tenant header the storefront always sends. Missing/invalid JWT → 401 {code: "unauthenticated"} on every route below.

Lazy registration: /customers/me resolves the customer by (client_id, JWT email) and creates the row on first authenticated call (has_account=true, account_status per the store's approval policy) — a fresh login never 404s on "me".

customer.account_status (pending | approved) is on every customer payload; when the store's B2B approval policy is on, hide B2B content until approved.

The executable blocks below prove the auth boundary (401 contract) — the docs harness is anonymous, so happy paths are shown as jsonc shapes and are pinned by tests/contract/customers.contract.test.ts and tests/store/customer-accounts-*.test.ts with real sessions.

POST /api/store/customers — guest-to-registered conversion

  • Purpose: bind a password sign-up to a customer row. Flow: supabase.auth.signUp({email, password}) client-side → this call with the same email. NOT needed for the passwordless code flow (verify already lazy-creates the customer).
  • Auth: Bearer JWT (+ x-client-id). Body email MUST equal the JWT email.
  • Request:
// POST /api/store/customers
{
  "email": "maria@example.com",   // must match the session email
  "first_name": "Maria",           // optional
  "last_name": "Petrova",          // optional
  "phone": "+359888123456",        // optional
  "company_name": "Acme OOD"       // optional
}
  • Response: {customer} — the full customer with addresses embedded (empty on first create). See the shape under /customers/me below.
  • Errors: 401 unauthenticated · 403 email_mismatch (body email ≠ session email) · 400 validation_failed.
  • SDK: customers.createCustomer(client, input)
  • Components: account registration form (account pages family).
  • Settings: store approval policy decides account_status of a NEW row (pending when B2B approval is required, else approved).
# No session → the documented 401 contract (the auth boundary, executable).
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/store/customers" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"email": "doc-'$RUN'@doc.test"}')
test "$STATUS" = 401

GET /api/store/customers/me — the signed-in customer

  • Purpose: hydrate account state after login / on account pages. Lazy-creates the customer row on first call for this (tenant, email).
  • Auth: Bearer JWT (+ x-client-id).
  • Request: no params.
  • Response:
{
  "customer": {
    "id": "uuid",
    "client_id": "uuid",
    "email": "maria@example.com",
    "first_name": "Maria",
    "last_name": "Petrova",
    "phone": "+359888123456",
    "company_name": null,
    "company_eik": null,            // Bulgarian company id (ЕИК) — invoice checkout
    "vat_number": null,
    "has_account": true,
    "account_status": "approved",   // "pending" | "approved" — B2B gating
    "tags": [],                      // admin-only labels; read-only here
    "metadata": null,
    "created_by": null,
    "created_at": "ISO-8601",
    "updated_at": "ISO-8601",
    "deleted_at": null,
    "addresses": [ /* CustomerAddress[], created_at asc — shape below */ ]
  }
}
  • Errors: 401 unauthenticated · 400 missing_client_id.
  • SDK: customers.getMe(client)
  • Components: account dashboard / header account state.
  • Settings: approval policy (account_status on lazy create).
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/store/customers/me" \
  -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 401
# The error envelope carries the stable code:
curl -s "$BASE/api/store/customers/me" -H "x-client-id: $CLIENT_ID" \
  | grep -q '"code":"unauthenticated"'

POST /api/store/customers/me — update own profile

  • Purpose: profile edit (names, phone, company/VAT master data).
  • Auth: Bearer JWT (+ x-client-id).
  • Request (all optional; nullable fields clear with null):
{
  "first_name": "Maria",
  "last_name": "Petrova",
  "phone": "+359888123456",
  "company_name": "Acme OOD",
  "company_eik": "123456789",   // loose format — foreign B2B customers exist
  "vat_number": "BG123456789",
  "metadata": {}
  // "tags" are admin-only — the server STRIPS them from this body.
}
  • Response: {customer} — the refreshed customer (shape above).
  • Errors: 401 unauthenticated · 400 validation_failed.
  • SDK: customers.updateMe(client, input)
  • Components: account profile form; invoice-details step in checkout.
  • Settings: company_eik/vat_number feed invoice-required checkout (order-documents).

GET /api/store/customers/me/addresses — list addresses

  • Purpose: the address book. Returns every address (created_at asc) — the envelope is nominal: offset is always 0 and limit equals count; no query params are read.
  • Auth: Bearer JWT (+ x-client-id).
  • Response:
{
  "addresses": [
    {
      "id": "uuid",
      "client_id": "uuid",
      "customer_id": "uuid",
      "address_name": "Home",
      "first_name": "Maria",
      "last_name": "Petrova",
      "company": null,
      "address_1": "ul. Ivan Vazov 1",
      "address_2": null,
      "city": "Sofia",
      "country_code": "bg",          // stored lower-case
      "province": null,
      "postal_code": "1000",
      "phone": "+359888123456",
      "is_default_billing": false,
      "is_default_shipping": true,
      "metadata": null,
      "created_at": "ISO-8601",
      "updated_at": "ISO-8601",
      "deleted_at": null
    }
  ],
  "count": 1,
  "offset": 0,
  "limit": 1
}
  • Errors: 401 unauthenticated.
  • SDK: customers.listAddresses(client)
  • Components: address book; checkout address picker.

POST /api/store/customers/me/addresses — create address

  • Purpose: add to the address book. is_default_billing / is_default_shipping: true clears the flag on every sibling first (one default per kind).
  • Auth: Bearer JWT (+ x-client-id).
  • Request: all fields optional — same keys as the address shape above (minus id/customer_id/timestamps). country_code is lower-cased.
  • Response: {customer} — the full refreshed customer (addresses embedded), NOT the created address alone; find it in customer.addresses.
  • Errors: 401 unauthenticated · 400 validation_failed.
  • SDK: customers.createAddress(client, input)
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
  "$BASE/api/store/customers/me/addresses" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"city": "Sofia"}')
test "$STATUS" = 401

GET /api/store/customers/me/addresses/:id — one address

  • Purpose: read one owned address (edit-form hydrate).
  • Auth: Bearer JWT (+ x-client-id).
  • Response: {address} — ONE address object (the only address endpoint that returns {address} instead of {customer}).
  • Errors: 401 unauthenticated · 404 not_found — unknown OR another customer's address; ownership violations 404, they never 403.
  • SDK: customers.getAddress(client, addressId)

POST /api/store/customers/me/addresses/:id — update address

  • Purpose: partial update; default flags clear siblings.
  • Auth: Bearer JWT (+ x-client-id).
  • Request: same keys as create, nullable to clear.
  • Response: {customer} — full refreshed customer.
  • Errors: 401 unauthenticated · 404 not_found · 400 validation_failed.
  • SDK: customers.updateAddress(client, addressId, input)

DELETE /api/store/customers/me/addresses/:id — delete address

  • Purpose: soft-delete an owned address.
  • Auth: Bearer JWT (+ x-client-id).
  • Response: {customer} — full refreshed customer (address gone).
  • Errors: 401 unauthenticated · 404 not_found.
  • SDK: customers.deleteAddress(client, addressId)

GET /api/store/customers/me/documents — my documents (invoices)

  • Purpose: the account "Invoices" page — the customer's issued (non-void) order documents, issued_at desc. Ownership is INNER-join enforced (session customer id AND client_id) — cross-customer reads are impossible (adversarial-tested).
  • Auth: Bearer JWT (+ x-client-id).
  • Request: query {limit? (≤200, default 20), offset?, doc_type?}.
  • Response:
{
  "documents": [
    {
      "id": "uuid",
      "order_id": "order_…",
      "order_display_id": 1042,
      "doc_type": "invoice",
      "number": "0000000042",
      "issued_at": "ISO-8601",
      "pdf_url": "https://…/invoice.pdf",
      "due_date": null,
      "paid_at": "ISO-8601"
    }
  ],
  "count": 1,
  "offset": 0,
  "limit": 20
}
  • Errors: 401 unauthenticated · 400 validation_failed.
  • SDK: customers.listMyDocuments(client, query?)
  • Components: account documents/invoices list.
  • Settings: order-documents issuing config (doc types, numbering).
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
  "$BASE/api/store/customers/me/documents" -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 401