Champagne

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 }
ArgumentBehavior
clubIdRequired club UUID; invoices must belong to that historical club. The UI defaults to Ukab Clubs.
pageRequired 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.
sortInvoiceSortField: number, client (payer name), issued (displayed creation timestamp, default), due, total (minor units), or status. Applied before pagination.
directionSortDirection: asc or desc (default). Each order uses ID in the same direction to break ties.
searchOptional, up to 200 characters. Trimmed case- and accent-insensitive substring match on invoice number, payer name/email or associated person's name.
statusOptional draft, open, paid, overdue, void or uncollectible. open excludes overdue invoices.
from, toOptional 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:

FieldMeaning
stateimporting before the first scan completes; syncing while queued work remains; ready when caught up; error when any job is awaiting a retry after failure.
processedNumber of distinct invoice IDs with a persisted projection, including unmatched/deleted IDs. Not a percentage or the number of matched invoices.
pendingUnfinished jobs, including the initial scan. Duplicate events have one durable job.
failedJobs with a recorded error, still scheduled for automatic retry.
unmatchedProjection rows excluded because they are outside the supported scope, have no local owner, or were deleted in Stripe.
lastSyncedAtNullable timestamp of the latest successful projection write, not proof that every invoice is current.
lastErrorNullable 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 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

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.

On this page