# Invoices

Browse sandbox subscription invoices, payments, PDF documents and full credit notes.

Source: https://docs.champagne.dev-ukab.fr/en/graphql/invoices

Stripe technical failures remain masked as `INTERNAL_SERVER_ERROR` and include a
server-generated `extensions.requestId` for the sanitized server diagnostic. The
original cause is retained only on the server. When Stripe reports an invoice
as missing (`resource_missing`), invoice retrieval during refresh or credit-note
creation retains its existing `null` result; it is not reported as a provider outage.

Finance opens the invoice list at `/sell`; `/sell/invoices/:invoiceId` opens a detail.
The selected club, search, status, creation date range and page are URL parameters.
Detail pages provide a named back link above their content, opposite the page actions, preserving the previous list filters.
Breadcrumbs appear only for paths with at least two levels. Opening a person from an invoice
retains the path Invoices / invoice number / People / person name and a back link to that invoice.
Section lists have no breadcrumb or back link. The interface retains loaded
invoices if a background refresh fails and offers an explicit retry.

This increment reads a **PostgreSQL projection of Stripe test data**, using the existing
billing configuration. List and detail queries never contact Stripe. It does not create
invoices, change numbering or recalculate taxes. Stripe remains authoritative for
snapshots, numbering, payments and PDFs. Reading never creates a payment or credit note.

## Scope [#scope]

Only EUR sandbox invoices whose subscription snapshot and current subscription contain
the current `champagne_iena_memberships_2026_v1` catalog marker or the retired
`champagne_basic_membership_monthly_v1` marker are included. Retaining the retired marker
keeps historical invoices readable after catalog cleanup. The current subscription's
`personId` identifies the Champagne profile; historical invoice metadata may contain an
older identity. The required `clubId` restricts the invoice's historical club, even when
several clubs share one merchant account. All Stripe reads use the connected account.
Memberships match that profile and the invoice line's Stripe Price ID (`stripePriceId`),
according to the plan-based membership model. A shared Price never identifies a person.
A missing membership does not hide an unpaid invoice. Payer details remain the immutable
invoice snapshot and can differ from the current profile.

This does not import arbitrary Stripe customers, standalone invoices or Peoplevine
invoices. Manual invoice creation, broader plan catalogs, payer reassignment,
accounting exports and PCA/CA are separate increments. French invoicing compliance remains a separate concern. Invoice operations require
an authenticated administrator of the selected club. Live keys
and live invoices are not supported.

## List [#list]

```graphql
query Invoices(
  $clubId: ID! = "51000000-0000-4000-8000-000000000051"
  $page: Int! = 1
  $sort: InvoiceSortField
  $direction: SortDirection
  $search: String
  $status: InvoiceStatus
  $from: String
  $to: String
) {
  invoices(
    clubId: $clubId
    page: $page
    search: $search
    status: $status
    from: $from
    to: $to
    sort: $sort
    direction: $direction
  ) {
    configured
    total
    page
    pageSize
    sync {
      state
      processed
      pending
      failed
      unmatched
      lastSyncedAt
      lastError
    }
    items {
      id
      number
      status
      createdAt
      dueAt
      currency
      total
      amountPaid
      amountRemaining
      credited
      personId
      memberName
      clubId
      clubName
      payerName
      payerEmail
    }
  }
}
```

The query runs unchanged with empty variables (`{}`), loading page 1 for the
seeded Ukab Clubs club. To select another club, enter overrides
in the runner's Variables editor (a separate JSON block is not preloaded):

```json
{ "clubId": "51000000-0000-4000-8000-000000000051", "page": 1 }
```

| Argument     | Behavior                                                                                                                                                                     |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clubId`     | Required club UUID; invoices must belong to that historical club. The UI defaults to Ukab Clubs.                                                                             |
| `page`       | Required by the example, defaults to 1 in the API; 1–1,000,000. Fixed 10 rows per page. Out-of-range pages clamp to the last page.                                           |
| `sort`       | `InvoiceSortField`: `number`, `client` (payer name), `issued` (displayed creation timestamp, default), `due`, `total` (minor units), or `status`. Applied before pagination. |
| `direction`  | `SortDirection`: `asc` or `desc` (default). Each order uses ID in the same direction to break ties.                                                                          |
| `search`     | Optional, up to 200 characters. Trimmed case- and accent-insensitive substring match on invoice number, payer name/email or associated person's name.                        |
| `status`     | Optional `draft`, `open`, `paid`, `overdue`, `void` or `uncollectible`. `open` excludes overdue invoices.                                                                    |
| `from`, `to` | Optional real calendar dates in `YYYY-MM-DD`. Inclusive UTC creation dates; start must not exceed end. These filter Stripe creation time, which may precede finalization.    |

An invoice is overdue only when Stripe marks it open, its remaining amount is positive,
and its explicit due timestamp is before the server clock. Failed automatic collection
without a due timestamp remains open. Credit notes are displayed separately from payment
status: a paid invoice can also be fully credited.

By default, results are ordered by finalization timestamp (creation timestamp for drafts), newest
first, then descending ID. Nullable numbers and due dates follow database null ordering.
Text sorting for number, client, and status ignores case and accents; equivalent
values use ID as a tie-breaker. `createdAt` exposes that displayed timestamp. `total` counts
all scoped/filter-matching rows before pagination. Every amount is an integer in euro
cents; the response currency is `eur`. `number` and `dueAt` can be null for drafts or
automatically collected invoices. `amountPaid`, `amountRemaining` and `credited` are
provider values, not inferred from the total. `credited` includes pre- and post-payment
credit notes. Credit notes do not erase the original invoice total.

When Stripe is disabled, the query returns `configured: false`, empty items, zero total,
page 1, page size 10 and `sync: null`. With configuration, queries return the currently
imported rows, including during a Stripe outage. Database failures remain masked errors.
During initial import, `total` is the matching **local** count, not a promised final count.

### Synchronization contract [#synchronization-contract]

`sync` is nullable for disabled billing and older injected service implementations. The
configured runtime returns account synchronization state, not scoped by list filters:

| Field          | Meaning                                                                                                                                                           |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `state`        | `importing` before the first scan completes; `syncing` while queued work remains; `ready` when caught up; `error` when any job is awaiting a retry after failure. |
| `processed`    | Number of distinct invoice IDs with a persisted projection, including unmatched/deleted IDs. Not a percentage or the number of matched invoices.                  |
| `pending`      | Unfinished jobs, including the initial scan. Duplicate events have one durable job.                                                                               |
| `failed`       | Jobs with a recorded error, still scheduled for automatic retry.                                                                                                  |
| `unmatched`    | Projection rows excluded because they are outside the supported scope, have no local owner, or were deleted in Stripe.                                            |
| `lastSyncedAt` | Nullable timestamp of the latest successful projection write, not proof that every invoice is current.                                                            |
| `lastError`    | Nullable sanitized diagnostic; never contains Stripe payloads or credentials.                                                                                     |

The UI polls the local `invoices` query every 3 seconds while importing/syncing,
10 seconds after sync or request errors, and 30 seconds when idle to pick up webhook
changes. Polling pauses in hidden tabs and stops when billing is unconfigured.
Finance displays synchronization status, counts, last sync and errors. Cached rows remain visible on errors, and an empty importing
page explicitly indicates that results are not final. Refresh reads local data only;
worker retries are automatic, not triggered by the button.

### Durable worker [#durable-worker]

Backend startup schedules a scan for each distinct selected connected account when billing is configured.
`invoice_job` stores the pagination cursor, processed discovery count, attempts, next
attempt time, completion time and sanitized error. Each Stripe list page and its child
invoice jobs commit atomically. Restart resumes from the committed cursor; there is no
1,000-invoice ceiling. Initial discovery does not block HTTP startup.

The process polls PostgreSQL; there is no Redis. Workers claim jobs with transaction row
locks and `SKIP LOCKED`. Refresh and credit operations share a transaction-scoped advisory
lock per invoice, acquired **before** fetching Stripe. A lost process releases its locks
and rolls back unfinished work. A savepoint prevents partial snapshots on refresh errors.
Retries use exponential backoff from 2 seconds to 1 hour, indefinitely. Logs contain only
operation and attempt count. Job rows retain event IDs for deduplication; no automatic
retention/deletion policy is installed.

The existing raw-body `/api/webhooks/stripe` endpoint verifies signatures before inserting
event jobs. Subscribe to `invoice.*`, `credit_note.*`, and `invoice_payment.*` events at
the SDK-compatible Stripe API version on connected accounts. Live events are refused and
events require `event.account`; foreign accounts cannot mutate local projections.
Duplicate event IDs are ignored; events never overwrite snapshots directly. Instead the
worker retrieves current Stripe state, so delayed/out-of-order delivery cannot restore an
old event snapshot. The `invoice.paid` membership handler validates connected context and
still runs synchronously after invoice enqueueing; its failures still return HTTP 500.

An hourly full reconciliation scan also requeues known projections, covering missed
events, deleted drafts, unmatched owners that become available, and profile/membership
changes. A refresh that first sees a subscription invoice as paid also applies the
`invoice.paid` membership update, so a missed webhook cannot leave a paid membership
unpaid. Neither path renews a membership from an invoice settled only by credit notes. Associations and profile names reflect the last successful refresh, not immediate
local directory edits. Each invoice stores the complete typed detail JSON plus indexed
club/date/status columns and searchable text. Overdue status is derived at query time in
PostgreSQL, without waiting for a webhook. Count and page reads share a repeatable-read snapshot.

This worker requires a running backend process: scale-to-zero pauses work until restart.
Transactions intentionally span Stripe requests to guarantee serialization without leases;
large invoices occupy a database connection while their pages load. Resource jobs, cursors
and locks are qualified by connected account; a shared account is scanned once. Historical
club attribution comes from subscription context, not account identity alone.

## Detail [#detail]

```graphql
query Invoice($id: ID! = "in_example", $clubId: ID! = "51000000-0000-4000-8000-000000000051") {
  invoice(id: $id, clubId: $clubId) {
    id
    number
    status
    createdAt
    dueAt
    currency
    total
    subtotal
    totalExcludingTax
    amountPaid
    amountRemaining
    credited
    personId
    memberName
    clubId
    clubName
    subscriptionId
    membership {
      id
      stripePriceId
      planId
      expirationDate
    }
    issuerName
    payerName
    payerEmail
    payerAddress
    taxIds
    pdfUrl
    paymentUrl
    creditableAmount
    taxes {
      label
      rate
      amount
    }
    lines {
      id
      description
      plan {
        name
        amount
        currency
        interval
      }
      proration
      quantity
      amount
      periodStart
      periodEnd
    }
    payments {
      id
      amount
      status
      paidAt
    }
    creditNotes {
      id
      number
      amount
      status
      createdAt
      memo
      pdfUrl
    }
  }
}
```

The example runs with `{}` using the illustrative ID `in_example`, normally returning
`null`; no invoice is created or seeded. To load a real detail, run the list query
first, then set `id` in Variables to an `items[].id` from its response. Invoice IDs
belong to the configured Stripe sandbox and cannot be hard-coded portably.

`id` must start with `in_` followed by 1–100 alphanumeric characters. `clubId` is a required
UUID. Disabled billing and invoices outside the selected scope return null. An unknown
local ID (including not-yet-imported IDs) returns null. A Stripe outage does not break a
previously imported detail. Database failures return a masked error.

Detail includes all summary fields and:

* Payer snapshot, account name, tax IDs, associated subscription and person link.
* `membership`: the local membership matching this profile and invoice Price, or null. The UI
  explicitly distinguishes an associated profile from a recorded membership. Reading
  invoices never creates memberships; paid memberships are recorded by the signed
  `invoice.paid` webhook. The worktree manages its own Stripe listener. A missing
  membership can mean delivery is pending or payment validation failed.
* Subtotal before discounts/taxes and provider total excluding tax (nullable if unspecified).
  Total includes tax and adjustments. Individual line amounts retain Stripe's semantics,
  including inclusive taxes where applicable; the PDF is the authoritative document.
* Tax amounts and the referenced tax rate's display name/percentage, where available.
* Invoice lines with quantity and service period; payment records with their status,
  amount and paid timestamp. `amount` is the amount received once the payment is `paid`,
  otherwise the amount the payment requests; `paidAt` stays null until it is paid. All credit
  notes, including voided notes.
* `lines[].plan`: the plan behind the line's Stripe Price (name, and the Price's amount in minor
  units, currency and `month`/`year` interval), or null when the Price was not created by
  Champagne. `lines[].proration` marks Stripe proration adjustments. Clients compose the line
  label from these fields in the reader's language; `description` is Stripe's own English
  text (nullable), kept only as a fallback for lines without a plan.
* Stripe's PDF URL and a hosted payment URL only for open invoices with money remaining.
  Only HTTPS Stripe-domain document/payment URLs are exposed. Links open in a new tab.
* Remaining creditable amount for paid/open invoices, based on original total minus
  existing credits. Provider restrictions such as separate refunds can further reduce
  the amount Stripe permits to be credited; Stripe remains authoritative.

Synchronization follows every page of invoice lines, invoice payments and credit notes
(100 per provider request), and resolves all referenced invoice tax rates. A projection
is committed only when the complete detail has loaded; errors preserve its previous value.

## Refresh an invoice [#refresh-an-invoice]

```graphql
mutation RefreshInvoice($id: ID!, $clubId: ID!) {
  refreshInvoice(id: $id, clubId: $clubId) {
    id
    status
    credited
    creditableAmount
  }
}
```

Both arguments are required and validated as for detail. `refreshInvoice` returns nullable
`InvoiceDetail`, with the same selectable fields as `invoice`. It verifies historical club
ownership and rereads the resource in its connected account, updating the projection.
An invoice from another club cannot be refreshed simply because the merchant is shared.
Finance synchronizes the visible invoice with this mutation on opening, on tab focus, and
every minute while visible. Lists automatically reread local rows.
Failures are explicit and the UI retains previously loaded detail.

## Create a full credit note [#create-a-full-credit-note]

```graphql
mutation CreditInvoice($id: ID!, $clubId: ID!, $expectedAmount: Int!, $memo: String!) {
  creditInvoice(id: $id, clubId: $clubId, expectedAmount: $expectedAmount, memo: $memo) {
    id
    credited
    creditableAmount
    amountRemaining
    creditNotes {
      id
      number
      amount
      status
      createdAt
      memo
      pdfUrl
    }
  }
}
```

This is an explicit **sandbox write**. `clubId` is required. `expectedAmount` is the positive
amount shown in the detail. `memo` must contain 3–500 characters after trimming (the raw
input also cannot exceed 500) and no null characters. Invalid arguments return
`BAD_USER_INPUT`; provider/internal errors are masked.

The UI asks for a reason and confirmation, and disables repeated submission while pending.
The server reloads the invoice from live Stripe (not the projection) and verifies scope,
eligibility and the expected amount under the same per-invoice lock used by the worker.
A successful credit reduces the unpaid amount; any post-payment part becomes customer
balance credit for future invoices. It does **not** refund a card, cancel a subscription,
change membership access, or send an email (`email_type=none`). Stripe calculates the split
and records its official credit note and PDF. The response is the refreshed detail, written
to the local projection before returning. A refresh job is also queued before the action;
periodic reconciliation provides recovery if a process dies around the external write.

The operation uses a stable Stripe idempotency key per invoice. A retry after a completed
full credit returns the current invoice without another write; concurrent matching calls
share Stripe's idempotency protection. If invoice state changes or Stripe rejects the
amount, refresh before retrying. An error after credit creation can mean the write succeeded
but the refreshed detail failed: inspect credit notes before taking further action.
Partial credits, card refunds and voiding credit notes are not exposed in this increment.

## Member outstanding invoices [#member-outstanding-invoices]

`myOutstandingInvoices(personId: ID!, clubId: ID!, page: Int! = 1)` requires a member
session matching both identifiers. Staff, applicant-only and anonymous sessions are refused.
The separate administrator invoice queries retain their existing authorization.

```graphql
query MyOutstandingInvoices($personId: ID!, $clubId: ID!, $page: Int! = 1) {
  myOutstandingInvoices(personId: $personId, clubId: $clubId, page: $page) {
    configured
    page
    hasMore
    items {
      id
      number
      amountRemaining
      currency
      dueAt
      pdfUrl
      paymentUrl
    }
  }
}
```

The query reads synchronized invoices for the club's connected account and the exact
member. It includes only open invoices with a positive remaining amount, including overdue
invoices and partially paid invoices. It does not require an active membership. Paid, void,
draft, uncollectible and fully credited invoices are excluded. The existing EUR sandbox
catalog scope applies; this is not a complete accounting balance or an enterprise payer workflow.

Pages contain at most 20 items, ordered by creation date then ID descending. `page` accepts
1–1,000,000; `hasMore` indicates a following page. Amounts are minor currency units and dates
are ISO timestamps. No customer contact details, tax information or synchronization diagnostics
are exposed. Payment and PDF links accept only HTTPS Stripe URLs without embedded credentials.
`configured: false` means invoice access is unavailable, not that nothing is owed.

The consumer shows this section in My account and automatically refreshes it. It rechecks every 15 seconds while visible and on window focus, preserving loaded rows
when a refresh fails. Payment opens Stripe in another tab. Only synchronized invoice state can
remove an invoice from this list; opening a link, returning to the app or changing a card does
not mark it paid. When balances change, the account is refreshed too. Reading never triggers a
charge, invoice creation or credit. Existing signed Stripe webhook processing remains responsible
for confirming the paid membership period; synchronization delays remain possible.

## Member invoice history [#member-invoice-history]

`myInvoices(personId: ID!, clubId: ID!, page: Int! = 1): MemberInvoicePage!`
returns the authenticated member's issued invoices from the local Stripe projection.
Only that member's session, person and club are accepted; staff and applicant access
is rejected. Page numbers must be between 1 and 1,000,000.

```graphql
query MyInvoiceHistory($personId: ID!, $clubId: ID!, $page: Int!) {
  myInvoices(personId: $personId, clubId: $clubId, page: $page) {
    configured
    page
    hasMore
    items {
      id
      number
      status
      createdAt
      total
      currency
      pdfUrl
    }
  }
}
```

The response contains up to 20 invoices per page, newest emission first, with invoice
ID as a stable tie-breaker. It includes open, overdue, paid, void and uncollectible
invoices; drafts and unmatched records are excluded before pagination. Paid invoices
remain available even without an active membership. `total` is the invoice total
in minor currency units, not the remaining balance; `createdAt` is the emission time.
PDF links are nullable and restricted to HTTPS Stripe URLs. No payer contact data,
internal notes or staff actions are exposed. `configured: false` distinguishes an
unavailable merchant from an empty history. Existing catalog and sandbox scope applies;
this is not an import of historical PeopleVine invoices.

The consumer's **My invoices** section displays number, emission date, total, status
and PDF link. The account automatically refreshes both history and
outstanding invoices; returning to the window also refreshes history. Changes detected
in outstanding balances invalidate the history. Cached rows remain visible if a
background refresh fails. **Outstanding invoices** remains the payment entry point.
