Skip to content

Fetch billing invoices via GraphQL#2004

Open
GregorShear wants to merge 16 commits into
greg/billing-tenant-keyed-fetchfrom
greg/billing-invoices-gql
Open

Fetch billing invoices via GraphQL#2004
GregorShear wants to merge 16 commits into
greg/billing-tenant-keyed-fetchfrom
greg/billing-invoices-gql

Conversation

@GregorShear

Copy link
Copy Markdown
Contributor

What

Swap the transport behind useBillingInvoices from the PostgREST invoices_ext view to the GraphQL tenant(name).billing.invoices connection. Internal to the hook — no consumer changes.

Changes

  • New TenantBillingInvoices query (src/api/gql/billing.ts).
  • useBillingInvoices maps the GQL node back to the existing Invoice shape: billed_prefix = the queried tenant, lowercased invoiceType, JSON lineItems / extra cast to the established types. Money stays integer cents.
  • GraphQL's InvoiceFilter can't express the old "six-month window OR any manual invoice, newest-first" predicate, so that filter + sort are reproduced client-side over a generous fetch limit.
  • Register Tenant / TenantBilling / Invoice as keyless in the urql graphcache (these types have no id), matching the existing convention.
  • Remove the now-unused getInvoicesBetween PostgREST query.

Verification

Validated locally against seeded data (final / preview / manual invoices):

  • newest-first sort; the window filter drops an out-of-window final while the manual carve-out keeps an out-of-window manual
  • line-items table renders from the line_items JSON; money formats correctly; extra usage drives the history columns and the usage graph
  • extra: null on manual invoices renders without crashing
  • preview hides the PDF / pay buttons, finals show them; the "Task usage" tooltip appears
  • billing-unconfigured tenants degrade to the empty state (no error banner, no crash)

Stacked on greg/billing-tenant-keyed-fetch.

* Theme the Stripe payment form for dark mode

* gql api changed - running codegen
@GregorShear GregorShear force-pushed the greg/billing-invoices-gql branch from 45430fd to 9209897 Compare June 17, 2026 00:02
* Migrate refresh token UI from PostgREST to GraphQL

* rework refresh token UI

* clean up

* Adopt gql updates

* remove validFor from refresh token query - not exposed anymore

* Regenerate gql-types against current schema

Resyncs the generated GraphQL types with the live schema after rebasing onto main. Drops the service-account schema (ApiKeyInfo, ServiceAccount, createServiceAccount, revokeApiKey, serviceAccounts, the ManageServiceAccounts capability bit, TenantFilter) that an earlier codegen run had incidentally captured, and removes validFor from the RefreshTokenInfo output type and the RefreshTokens query, which the server no longer exposes.

* Clean up

* intl strings

* address review feedback on refresh token table

- Gate create dialog dismissal on the in-flight create state so backdrop/Escape can't close mid-request and orphan the new token's secret

- Realign table headers with their cells: add a Uses header and move Status over the expired column

- Suppress the empty-state message on load error so it no longer renders beneath the error banner

- Recover from an empty non-first page with a useEffect (mirrors AccessLinksTable) instead of the row-level page-back heuristic, and drop the now-unused onRevoked prop; revoke marks a token expired rather than removing it, so a revoke never empties the page
Billing invoices are now fetched through a tenant-keyed SWR hook
(useBillingInvoices) instead of an imperative fetch into the Billing
store. Because the SWR key derives from the selected tenant and the
rolling six-month window, switching orgs re-fetches automatically and
never shows the previous tenant's data — so the store no longer needs to
be manually reset when the tenant changes.

Removes the useTenantChangeReset hook, the unmount reset in AdminBilling,
and the StoreWithHydration machinery (invoices, active/hydrated/
networkFailed/hydrationErrorsExist, resetState) from the Billing store,
which is now just the invoice selection and paymentMethodExists. The
selected invoice resolves to the stored selection or falls back to the
newest invoice, so a stale selection from another tenant self-corrects.

All invoice/loading/error reads across the billing page, history table,
line-items table, usage graphs, and graph-state wrapper now come from the
hook. SWR dedupes the shared key to a single request, and revisiting a
tenant is served instantly from cache.
Swap the transport behind useBillingInvoices from the PostgREST
invoices_ext view to the GraphQL tenant(name).billing.invoices
connection. The hook's public shape is unchanged, so no consumer is
touched — the invoice list, history table, line-items table, usage
graphs, and graph-state wrapper all keep reading through the hook.

The GQL invoice node is camelCased, returns invoiceType as an enum, and
omits billed_prefix (the tenant is the query parent), so the hook maps
each node back to the existing Invoice shape (billed_prefix = the queried
tenant, lowercased invoice_type, JSON line items/extra cast to the
established types). Money fields stay integer cents, matching the
existing /100 display.

GraphQL's InvoiceFilter is narrower than the old PostgREST predicate (no
"window OR manual" clause, only gt/lt date bounds), so the six-month
window + manual-invoice filter and the newest-first sort are reproduced
client-side over a generous fetch limit. urql keys its cache on the query
variables, preserving the tenant-keyed refetch and instant cached
revisits. Removes the now-unused getInvoicesBetween PostgREST query.

Note: the line-item/extra JSON field shapes are assumed identical to the
PostgREST payload (same data source); pending runtime verification.
The TenantBillingInvoices query selects Tenant, TenantBilling, and Invoice,
none of which expose an id/_id, so graphcache logged 'Invalid key' warnings
and fell back to embedding them on the parent. Add the three types to the
cacheExchange keys config returning null (the same convention already used
for Alert/LiveSpecRef/etc.), making the embed-on-parent behavior explicit
and silencing the warnings. Read-only billing data fetched per tenant, so
normalization isn't needed.
The Recent History table was hard-capped at the 4 most recent invoices
within the rolling six-month window (a slice(0, 4) placeholder). Show all
of a tenant's invoices instead, paginated four per page (PRD #2).

- useBillingInvoices now exposes allInvoices (the full newest-first list)
  alongside invoices (the windowed+manual subset the usage graphs chart).
  selectedInvoice resolves against the full list, so any row is clickable.
- BillingHistoryTable paginates allInvoices with MUI TablePagination +
  the shared TablePaginationActions, resetting to the first page when the
  tenant changes. Rows no longer slices.

The usage graphs and graph-state wrapper still read the windowed
invoices, so they remain a six-month view. Fetch is capped at 100
invoices; true cursor pagination beyond that is a later step.
The GraphQL invoice node carries the Stripe-sourced fields inline
(status, invoicePdf, hostedInvoiceUrl, paymentDetails.receiptUrl), so the
line-items view no longer needs a separate getTenantInvoice round-trip to
the billing edge function to render the download/pay/receipt buttons.

- Add those fields to the TenantBillingInvoices query and carry them onto
  the Invoice shape via the hook mapping.
- BillLineItems reads them off the selected invoice directly: drops the
  getTenantInvoice fetch, the stripeInvoice state, and the loading
  skeletons.
- Add a "View Receipt" button shown when the invoice has a receipt URL
  (PRD #3 — receipts for paid invoices).
- Remove the now-unused getTenantInvoice / StripeInvoice from the billing
  API.

Verified locally that the new fields are requested and the page renders
(seeded data has no Stripe linkage, so the fields come back null and the
buttons degrade to disabled, with no getTenantInvoice call). Populated
PDF/receipt rendering needs a Stripe-enabled environment.
When the selected invoice has no Stripe artifact (no PDF, hosted page,
receipt, or status), replace the disabled Download/Pay buttons with a
single 'No invoice available' button rather than two dead controls.

The external invoice links (Download PDF, Pay Invoice, View Receipt) now
open in a new tab (component=a + target=_blank, rel=noopener noreferrer)
so they don't navigate the user out of the app.
BillingHistoryTable reset to the first page whenever the allInvoices array changed reference. The billing query is keyless and revalidates on the 30s requestPolicy TTL, so a background refetch handed back a new but identical array and bounced the user back to page one. Key the reset on the selected tenant instead, the only case where returning to the newest invoices is wanted, and expose selectedTenant from useBillingInvoices. The existing currentPage clamp still keeps the view in range when the invoice count changes.
In narrow layouts the End Date wrapped to two lines because the combined Data / Tasks cell was set to nowrap and claimed the available width. Let the Data / Tasks cell wrap instead, and set the date cell to nowrap so the End Date stays on one line.
Move the payment-methods admin flow off the Supabase `billing` edge function onto GraphQL. A new useBillingPaymentMethods hook reads the list via tenant.billing.paymentMethods and wraps the createBillingSetupIntent, setBillingPaymentMethod, and deleteBillingPaymentMethod mutations, so the Add dialog's SetupIntent secret and the set-primary/delete actions now go through GraphQL. The edge function is kept only for the multi-tenant trial-warning batch (getPaymentMethodsForTenants); the now-dead REST exports are removed.

Table and UX changes:
- Extract the Add Payment Method button out of the dialog; it is full-width and stacked under the header on narrow screens.
- Replace the Primary column and the per-row Make Primary / Delete buttons with a single actions column: a star (filled = primary, outline-on-hover = make primary) and a hover-revealed red trash icon, each with a tooltip.
- Set-primary updates optimistically and rolls back if the mutation fails; the list is only refetched on add/delete, not on a primary toggle.
- Confirm before deleting a payment method, and reload the list before dismissing the dialog.
- Center the Last 4 / Exp / actions columns and slim PaymentMethodProps to the fields actually rendered.

Regenerate gql-types for the new operations.
@GregorShear GregorShear force-pushed the greg/billing-invoices-gql branch from 2b0cf99 to 6a0159b Compare June 18, 2026 13:31
Replace FormattedMessage/useIntl usage across the billing admin page, graphs, and invoice/payment-method tables with inline English copy, and remove the message keys that this orphans from AdminPage.ts.

Swap intl.formatDate/FormattedDate for date-fns format and intl.formatNumber for native toLocaleString, dropping react-intl from the graph, totals, and timestamp components entirely. Reproduce the taskHoursByMonth ICU plural as a JS ternary. Unwind the TimeStamp tooltipMessageId indirection into a getTooltip callback and the PaymentMethods column headers into literal labels.

Table column headers and empty-state messages rendered by the shared EntityTableHeader/EntityTableBody still use message keys, since those components are outside this change and shared across many tables.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant