Invoices
Browse sandbox subscription invoices, payments, PDF documents and full credit notes.
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
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
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):
{ "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
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
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
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 signedinvoice.paidwebhook. 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.
amountis the amount received once the payment ispaid, otherwise the amount the payment requests;paidAtstays 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 andmonth/yearinterval), or null when the Price was not created by Champagne.lines[].prorationmarks Stripe proration adjustments. Clients compose the line label from these fields in the reader's language;descriptionis 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
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
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
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.
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
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.
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.