Surface invoice PDF/receipt links from GraphQL#2005
Closed
GregorShear wants to merge 21 commits into
Closed
Conversation
…m home and admin pages
The org menu replaced the per-page TenantSelector (removed from the home and admin pages), which was what previously defaulted the selected tenant. Since OrgMenu is always mounted in the nav, default selectedTenant to the first admin-capable tenant when none is validly selected, while preserving a still-valid persisted selection.
Brings the org menu to parity with the removed per-page TenantSelector: - Honor a ?prefix= URL param (e.g. the billing add-payment-method CTA) the first time it appears, then keep a manual selection. A ref tracks the applied param so a stale value lingering in the URL doesn't override later org switches. - Drop the support-only dialog that listed the full prefix set (allPrefixes); support users now see the same top-level org list (tenantsWithAdmin) as everyone else, matching what the old selector showed.
Reverts the dialog removal from the previous commit. estuary-support users admin a large number of tenants and need a searchable list, so they keep the autocomplete dialog over the full admin prefix set; regular users keep the simple top-level org menu. Also fixes the defaulting effect to validate the current selection against the list each user actually sees (allPrefixes for support, tenantsWithAdmin otherwise) instead of always against the top-level orgs, so a support user's selection isn't reset to the first top-level org.
The support dialog was listing allPrefixes (useEntitiesStore_capabilities_adminable) — the full admin prefix set incl. sub-prefixes, which no tenant selector showed before this PR. Switch it to useEntitiesStore_tenantsWithAdmin, the exact list the old TenantSelector showed every user (regular and support), so support users get the identical top-level org list they had pre-change, just in the searchable dialog. Both selectors now share one list, so the defaulting effect no longer needs a per-user list.
When the click-to-accept (ToS/Privacy) check fails to fetch, LegalGuard renders a full-page error in place of the children — before the layout, and the now-relocated logout button in the nav, can render. That left a stuck user no way to sign out (logging out and back in clears a stale check). Add an optional actions slot to FullPageError and pass a Log out button (supabaseClient.auth.signOut()) from LegalGuard's error branch. The ToS accept screen already had this via the Actions cancel button; this fills the gap on the error screen.
Every FullPageError call site is behind auth (LegalGuard, UserInfoSummary, TenantBillingDetails, and the entities/storage-mappings hydrators) and is a dead-end a stuck user may need to escape. Move the Log out button into FullPageError itself and drop the opt-in actions prop / LegalGuard wiring added in the previous commit, so all full-page errors get the escape hatch consistently.
Most full-page errors (failed data hydration or fetches) are transient, so offer a page reload as the primary recovery action, with the logout escape hatch as the secondary.
Tenant selection is global app state, but it was being initialized inside OrgMenu, a presentational nav component. That made correctness depend on the menu's mount lifecycle. Extract the effect into useInitializeSelectedTenant and call it from TenantGuard, which only renders the app for users with tenant access. OrgMenu becomes a pure consumer of selectedTenant. Behavior is unchanged.
It reads the identity-provider-asserted user_metadata.email_verified, not Supabase's authoritative email_confirmed_at. Since production only supports SSO and social login, the provider always vouches for the email, so the field is true for practically every user (confirmed against prod) and is not a meaningful UI or analytics signal.
- ListItemLink takes a plain string title (callers format i18n), splits the overloaded link prop into explicit to/onClick, and gains an optional tooltip (defaults to title). Unused props and the dead NavWidths.FULL branch are gone. - All side-nav rows (route links, collapse toggle, help/user/org triggers) now render through ListItemLink, so NavTriggerButton is deleted. - UserMenu and OrgMenu are pure floating surfaces taking anchorEl/onClose, matching HelpMenu; Navigation owns the trigger rows and anchor state. OrgMenu picks the support dialog vs the popover internally from hasSupportAccess. - The lower nav section gets a distinct aria-label (navigation.ariaLabel.secondary).
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 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.
45430fd to
9209897
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Render the invoice PDF / pay / receipt links from the GraphQL invoice node instead of a separate Stripe round-trip — and add a View Receipt button (PRD #3, receipts for paid invoices).
Why
The GQL invoice node carries the Stripe-sourced fields inline (
status,invoicePdf,hostedInvoiceUrl,paymentDetails.receiptUrl) — data the old PostgREST view didn't have, which is whyBillLineItemsmade a separategetTenantInvoicecall to the billing edge function on every invoice selection. Now those fields come with the invoice, so the extra request (and its loading state) goes away.Changes
status,invoicePdf,hostedInvoiceUrl,paymentDetails { status receiptUrl }to theTenantBillingInvoicesquery; carry them onto theInvoiceshape via the hook mapping.BillLineItemsreads them off the selected invoice directly — drops thegetTenantInvoicefetch, thestripeInvoicestate, and the loading skeletons.getTenantInvoice/StripeInvoicefromsrc/api/billing.ts.Verification
Locally the new fields are requested and returned; seeded data has no Stripe linkage, so they come back
nulland the buttons degrade to disabled — line items and totals still render, with nogetTenantInvoicecall (one fewer round-trip, and it removes the edge-function 500s that fired on invoice selection locally).Needs a Stripe-enabled environment to confirm the populated PDF / pay / receipt rendering and that
Invoice.statusmatches the expected Stripe invoice statuses (open/paid) the pay/paid buttons key off.Stacked on
greg/billing-invoices-gql(#2004).