diff --git a/cmd/start.go b/cmd/start.go index 93aa9d38..58c586e4 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -341,6 +341,17 @@ var startCmd = &cobra.Command{ startStripeEnvironmentCheck(ctx, temporalClient, workerCfg.TaskQueue, database, stripeKeyFingerprint, logger) } + // The Stripe public-details read (merchant-disclosure D1): the + // business name, statement descriptor and icon members meet in + // Stripe's pages live in Stripe's settings and change without + // the console knowing, so every boot reads them again. At most + // four calls, started and not awaited, for the reason above. + if stripeMode != "" { + if _, err := stripewf.StartPublicDetailsRead(ctx, temporalClient, workerCfg.TaskQueue, stripewf.PublicDetailsTriggerBoot); err != nil { + logger.Error("failed to start the stripe public details read", slog.Any("error", err)) + } + } + // Set up the billing scheduled-change sweep — the backstop firing // path for due subscription scheduled changes. Period-end // cancellations also fire via the Stripe deletion webhook, so a diff --git a/docs/design-system.md b/docs/design-system.md index 96aeacf5..1d5d90eb 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -466,7 +466,7 @@ exists. | Part | Renders | Value | |---|---|---| | `pageHeader` | the page title, `h1.h2`, with the location trail above it (§5 "Trail": a Bootstrap breadcrumb rooted at the surface, then the rail entry and any real intermediate pages, then the current page unlinked; suppressed only on the surface's own root), an optional one-sentence lead below it, and one right-hand slot holding a muted count **or** one action control, always `btn-outline-secondary` and never filled (design D19); the only `

` on a page. A record whose name the identity provider owns marks that field's own `
` label with the `managedGlyph` part instead of marking the title (the person page today; design D13, round 5) | `server.PageHeader`; `Check()` aborts the render when both a count and an action are set | -| `sectionHeader` | an in-page section title, `h2.h5`, with an optional muted count or one action, titled outside the box its content sits in. The action is a filled link when `Link.Filled` marks it as the section's one call to action (a list's "New ..." link to the create page), an outline-secondary link otherwise, and an outline-secondary opener carrying the `openerIcon` plus glyph when `Link.Toggle` names a collapsed panel (§2 "Buttons") | `server.SectionHeader` | +| `sectionHeader` | an in-page section title, `h2.h5`, with an optional muted count or one action, titled outside the box its content sits in. The action is a filled link when `Link.Filled` marks it as the section's one call to action (a list's "New ..." link to the create page), an outline-secondary link otherwise, an outline-secondary opener carrying the `openerIcon` plus glyph when `Link.Toggle` names a collapsed panel (§2 "Buttons"), and an outline-secondary refresh button without a glyph when `Link.Refresh` names a route that reads the section's state again: it posts there and swaps `Link.Target`, the section's body, whose first line says when the state was last read (the Stripe page's Check now and Read again) | `server.SectionHeader` | | `openerIcon` | the panel opener's leading plus glyph: an inline SVG, `aria-hidden`, that rotates into a cross while its button reports `aria-expanded="true"` | no value; used inside an opener's markup | | `chevronIcon` | the linked card's trailing chevron: an inline SVG, `aria-hidden`, in the muted colour, the mark that a card navigates ("A linked card", below) | no value; used inside a linked card's body | | `managedGlyph` | the identity-provider glyph a field's own `
` label carries when the provider, not the console, owns that field: an inline SVG, `aria-hidden`, plus visually-hidden text "Managed by the identity provider" and a matching title tooltip on the wrapping span (the person page's Name and Email labels today; design D13, round 5) | no value; used inside a label's markup | @@ -797,7 +797,11 @@ of one control and reads wrong above it; empty puts the message before the fields, which is right for a preview whose narrative introduces the choice. A commit disabled behind a precondition (`FormView.CommitState`) renders with the classes the enabled commit would -have carried, so it and the way out beside it are the same height. +have carried, so it and the way out beside it are the same height. A render +may name an element outside the form that states what committing costs +(`FormView.CommitDescribedBy`, the Products page's billing line); the part +writes it as `aria-describedby` on the enabled commit or a link commit, and +never on a disabled one, which is described by its reason. ### The section box hugs its fields diff --git a/docs/production-deployment.md b/docs/production-deployment.md index b690a53c..555fba91 100644 --- a/docs/production-deployment.md +++ b/docs/production-deployment.md @@ -185,6 +185,15 @@ it; set that value as `stripe-webhook-secret` (or point where only one is set, and treats both left empty as billing intentionally disabled. +### Stripe public details + +Before members can buy, set the Stripe account's public business name, +statement descriptor and icon, on the live account and on every sandbox the +deployment uses. Stripe fills the name and the descriptor from the legal +name until they are set, and members meet them on Checkout, on receipts and +on their card statement. [stripe.md](stripe.md#what-members-see) says where +they live, what the console shows members, and what the API key can read. + ## 7. First boot With the database reachable, `base-url` set, the session store's password diff --git a/docs/stripe.md b/docs/stripe.md index 13c186e4..5b2e2e77 100644 --- a/docs/stripe.md +++ b/docs/stripe.md @@ -30,10 +30,11 @@ key's prefix at boot (`sk_live_` and `rk_live_` are live, `sk_test_` and `rk_test_` are test); any other prefix fails boot. "View in Stripe" links carry the mode and nothing else: Stripe opens them in the account and context your dashboard session is in, and offers its own switch when the -object lives elsewhere. The console never reads or stores the account id: -reading it needs the Connect "Accounts Read" permission on a restricted -key, and a restricted key with only the permissions the console uses is -the recommended deployment. +object lives elsewhere. The console never stores the account id. It reads +the account only for the public details members see, and only when the +key permits it ("What members see" below). A restricted key holding only +the permissions the console uses is the recommended deployment, with +Accounts Read as an optional, read-only addition. Stripe's own `livemode` flag cannot narrow this further: a legacy test key and every sandbox report `livemode: false` alike, so the flag separates @@ -111,6 +112,58 @@ Subscribe to these event types: - `invoice.created`, `invoice.finalized`, `invoice.paid`, `invoice.payment_failed`, `invoice.voided` - `payment_method.attached`, `payment_method.detached`, `payment_method.updated` +## What members see + +Stripe Checkout's header, the receipts Stripe sends, and the member's card +statement carry two settings of the Stripe account, not of the console: the +public business name and the statement descriptor. Until they are set, +Stripe fills both from the account's registered legal name, which for an +individual's account can be a person's name. When that name differs from +the deployment's, a member first meets it above the card fields, and again +on a card statement they may not recognise and may dispute. + +Set them before members can buy: + +- **Public business name** and **statement descriptor**: in the Stripe + Dashboard under Settings, Business details. The descriptor is 5 to 22 + Latin characters and is what the card statement shows. +- **Icon**: under Settings, Branding. Checkout and receipts show it. + +A sandbox is a separate account with its own copy of these settings. Set +them in every sandbox a deployment uses, as well as on the live account. + +The console reads these settings at every boot, and again when an operator +presses **Read again** in the **Public details** section of the Stripe +integration page. That section shows each value, marks a business name or +descriptor that still equals the legal name, marks a missing icon, and +shows the exact line members see. On the Products page, one line under the +Plans heading and one under the add-ons heading read `Billed by …` and +`Card statement: …` when either value would not be recognised against +`deployment-name`, and the notice after a successful checkout repeats +them. When both values match the deployment's name, members see nothing +extra. + +### What the key can read + +The console reads the public details with the permissions its key already +holds: + +- A full secret key, or a restricted key with **Accounts Read**, reads the + account itself: the business name, the statement descriptor, whether an + icon is set, and whether the name or the descriptor equals the legal + name. +- A restricted key without it reads the business name from an invoice + preview, which creates nothing and uses the Invoices permission the plan + switch already needs, and the statement descriptor from the latest + charge once a payment exists. It cannot read + the icon or compare with the legal name. The Public details section + names each value it could not read and the permission that would allow + it. + +Accounts Read is read-only. On an ordinary account it reads that account's +profile, email, company details and status. On a Stripe Connect platform it +also reads every connected account, so weigh it there before adding it. + ## How it works The member console is the catalog source of truth; Stripe holds a mirror used for payment execution. Catalog objects do **not** sync to Stripe automatically — syncing a product to Stripe is an **explicit operator action** (see "Making a product purchasable" below). Once synced, members subscribe via Stripe Checkout, Stripe runs the billing cycle, and webhooks project Stripe state (subscriptions, invoices, payments) back into the member console's core tables. diff --git a/go.mod b/go.mod index 216ae5fa..85fe1c82 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( golang.org/x/net v0.59.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.23.0 + golang.org/x/text v0.42.0 golang.org/x/time v0.5.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -68,7 +69,6 @@ require ( go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/text v0.42.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/grpc v1.83.2 // indirect diff --git a/internal/embeds/templates/index.html b/internal/embeds/templates/index.html index ea08a972..d5533060 100644 --- a/internal/embeds/templates/index.html +++ b/internal/embeds/templates/index.html @@ -54,6 +54,11 @@ {{ else if eq .CheckoutStatus "cancel" }}