Show members who bills them before they pay, and archive the change
Stripe Checkout, receipts and the card statement carry the Stripe account's public business name and statement descriptor, which Stripe fills from the registered legal name until they are set. Where that name differs from the deployment's, a member first meets it above the card fields (#13). Each install has to notice this on its own, since a fix in one account's settings reaches no other deployment. - A Temporal workflow reads the public details at every boot and on request, with whatever permission the key holds: the account itself with Accounts Read, otherwise an invoice preview and the latest charge. The result is a JSON instance setting; the account id is never stored. - The Stripe provider page gains a Public details section. Its Read again control, and the Environment check's Check now, move into the section header as a refresh action. - The Products page and the checkout return name who bills the member and what the card statement says, only when a name would not be recognised against deployment-name. Every control that costs money, the switch preview's commit included, references the line through FormView.CommitDescribedBy. - docs/stripe.md gains "What members see"; production-deployment.md points to it. golang.org/x/text becomes a direct dependency for the name comparison. OpenSpec change merchant-disclosure is archived and its six deltas synced into openspec/specs/.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 `<h1>` on a page. A record whose name the identity provider owns marks that field's own `<dt>` 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 `<dt>` 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+57
-4
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -54,6 +54,11 @@
|
||||
<div class="alert alert-success" role="alert">
|
||||
<strong>Thanks!</strong> We're finalizing your upgrade; this can take a moment.
|
||||
<a href="/products">View your plan on the Products page</a>.
|
||||
{{/* The billing facts the Products page showed (merchant-disclosure
|
||||
D5), repeated where the member is most likely to remember them.
|
||||
A plain line: muted text in a coloured alert falls under the
|
||||
contrast floor. */}}
|
||||
{{ if .BillingFacts.Shown }}<div class="mt-1">{{ range $i, $s := .BillingFacts.Sentences }}{{ if $i }} {{ end }}{{ $s }}{{ end }}</div>{{ end }}
|
||||
</div>
|
||||
{{ else if eq .CheckoutStatus "cancel" }}
|
||||
<div class="alert alert-secondary" role="alert">
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
{{ if .HasPlans }}
|
||||
<p class="text-muted small mb-2">Products to add alongside your plan, or to use on their own.</p>
|
||||
{{ end }}
|
||||
{{/* Billing facts (merchant-disclosure D5): the add-ons section's own
|
||||
line, under its heading and lead, because the Add buttons can sit
|
||||
screens below the Plans line. */}}
|
||||
{{ template "billingFacts" .BillingFacts }}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm table-record align-middle">
|
||||
<thead>
|
||||
@@ -44,7 +48,7 @@
|
||||
primary style. */}}
|
||||
<button type="button" class="btn btn-outline-primary btn-sm ms-1"
|
||||
hx-post="/billing/checkout" hx-vals='{"price_id": "{{ .PriceID }}"}'
|
||||
hx-target="#addon-checkout-error-{{ .ProductID }}" hx-swap="innerHTML">Add</button>
|
||||
hx-target="#addon-checkout-error-{{ .ProductID }}" hx-swap="innerHTML"{{ if $.BillingFacts.Shown }} aria-describedby="{{ $.BillingFacts.ID }}"{{ end }}>Add</button>
|
||||
<div id="addon-checkout-error-{{ .ProductID }}" class="small text-danger mt-1"></div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
{{ else if eq (len .Ladders) 0 }}
|
||||
{{ template "emptyState" .Empty }}
|
||||
{{ else }}
|
||||
{{/* Billing facts (merchant-disclosure D5): who bills the member and what
|
||||
their card statement says, directly under the Plans heading, once for
|
||||
every ladder, only when a tier here offers a move that costs money
|
||||
and a name would not be recognised. */}}
|
||||
{{ template "billingFacts" .BillingFacts }}
|
||||
{{ if .Success }}
|
||||
<div class="alert alert-success" role="alert">
|
||||
{{ .Success }}
|
||||
@@ -105,12 +110,12 @@
|
||||
{{ if eq .MoveKind "checkout" }}
|
||||
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
|
||||
hx-post="/billing/checkout" hx-vals='{"price_id": "{{ .PriceID }}"}'
|
||||
hx-target="#checkout-error-{{ .MoveLadderID }}-{{ .ProductID }}" hx-swap="innerHTML">{{ .MoveLabel }}</button>
|
||||
hx-target="#checkout-error-{{ .MoveLadderID }}-{{ .ProductID }}" hx-swap="innerHTML"{{ if $.BillingFacts.Shown }} aria-describedby="{{ $.BillingFacts.ID }}"{{ end }}>{{ .MoveLabel }}</button>
|
||||
<div id="checkout-error-{{ .MoveLadderID }}-{{ .ProductID }}" class="small text-danger mt-1"></div>
|
||||
{{ else if eq .MoveKind "switch" }}
|
||||
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
|
||||
hx-get="/partials/member/plans/switch/preview" hx-vals='{"ladder_id": "{{ .MoveLadderID }}", "price_id": "{{ .PriceID }}"}'
|
||||
hx-target="#member-plans" hx-swap="innerHTML">{{ .MoveLabel }}</button>
|
||||
hx-target="#member-plans" hx-swap="innerHTML"{{ if $.BillingFacts.Shown }} aria-describedby="{{ $.BillingFacts.ID }}"{{ end }}>{{ .MoveLabel }}</button>
|
||||
{{ else if eq .MoveKind "cancel" }}
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-auto"
|
||||
data-bs-toggle="modal"
|
||||
|
||||
@@ -24,6 +24,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* Public details: the business name, statement descriptor and icon
|
||||
members meet in Stripe's pages, and what members see on the Products
|
||||
page because of them (merchant-disclosure D6). Only rendered when
|
||||
Stripe is configured: there is no account to read otherwise. */}}
|
||||
{{ if .Configured }}
|
||||
{{ template "sectionHeader" .PublicDetailsHeader }}
|
||||
{{ template "operator_integration_stripe_public_details.html" .PublicDetails }}
|
||||
{{ end }}
|
||||
{{/* Environment check: where the read-back of the mapped Stripe ids
|
||||
stands under the key in force (stripe-environment-stamp D3). Only
|
||||
rendered when Stripe is configured, because there is no key to read
|
||||
|
||||
@@ -3,16 +3,9 @@
|
||||
|
||||
{{/* operator_integration_stripe_environment.html
|
||||
The Environment check section's body (stripe-environment-stamp D3):
|
||||
one resting line and the control that runs the read-back again. Its
|
||||
own file, and its own id, because the control's POST re-renders this
|
||||
much and no more; the section header above it belongs to the page. */}}
|
||||
<div id="stripe-environment-check" class="mb-3">
|
||||
one resting line. Its own file, and its own id, because the header's
|
||||
Check now posts and re-renders this much and no more; the section
|
||||
header belongs to the page. */}}
|
||||
<div id="stripe-environment-check">
|
||||
<p class="small text-body-secondary">{{ .Line }}</p>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
hx-post="{{ routeURL "/partials/operator/integrations/stripe/environment-check" }}"
|
||||
hx-target="#stripe-environment-check"
|
||||
hx-swap="outerHTML"
|
||||
hx-sync="this:replace">
|
||||
Check now
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{{- /* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial */ -}}
|
||||
{{- /* SPDX-FileCopyrightText: 2025-2026 Christian Galo */ -}}
|
||||
|
||||
{{/* operator_integration_stripe_public_details.html
|
||||
The Public details section's body (merchant-disclosure D6): the
|
||||
resting line, then the business name, statement descriptor and icon
|
||||
members meet in Stripe's pages, as the last read found them, and what
|
||||
members are shown because of them. Its own file and its own id,
|
||||
because the header's Read again posts and re-renders this much and no
|
||||
more. The list sits in a card like the page's Details list. */}}
|
||||
<div id="stripe-public-details" class="mb-3">
|
||||
<p class="small text-body-secondary">{{ .Line }}</p>
|
||||
{{ if .Rows }}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
{{ range .Rows }}
|
||||
<dt class="col-sm-3">{{ .Label }}</dt>
|
||||
<dd class="col-sm-9">{{ with .Value }}{{ . }}{{ end }}{{ range .Notes }} <span class="small text-body-secondary">{{ . }}</span>{{ end }}{{ with .Reason }}<span class="text-body-secondary">{{ . }}</span>{{ end }}</dd>
|
||||
{{ end }}
|
||||
<dt class="col-sm-3">Shown to members</dt>
|
||||
<dd class="col-sm-9 mb-0">{{ .Shown }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
@@ -0,0 +1,14 @@
|
||||
{{- /* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial */ -}}
|
||||
{{- /* SPDX-FileCopyrightText: 2025-2026 Christian Galo */ -}}
|
||||
|
||||
{{/* Billing facts (merchant-disclosure D4, D5). Pipeline: server.BillingFacts.
|
||||
The one or two sentences naming who bills the member and what their
|
||||
card statement will say, shown only when either would not be
|
||||
recognised against the deployment's own name; renders nothing
|
||||
otherwise. One muted small paragraph, the add-ons lead sentence's
|
||||
style, carrying the id its section's checkout controls name in
|
||||
aria-describedby. Used under the Plans heading and under the add-ons
|
||||
heading. The dashboard's return-from-Stripe alert writes the same
|
||||
sentences as a plain line of its own, because muted text inside a
|
||||
coloured alert falls under the contrast floor. */}}
|
||||
{{ define "billingFacts" }}{{ if .Sentences }}<p{{ with .ID }} id="{{ . }}"{{ end }} class="text-muted small mb-2">{{ range $i, $s := .Sentences }}{{ if $i }} {{ end }}{{ $s }}{{ end }}</p>{{ end }}{{ end }}
|
||||
@@ -236,12 +236,14 @@
|
||||
cancelling (finding FA-19). The commit's classes come from the view
|
||||
(FormView.CommitClasses), so a disabled commit is the same weight and
|
||||
the same height as the enabled one it stands in for, and so is a
|
||||
commit that is a link out of the console (FormView.CommitLink). */}}
|
||||
commit that is a link out of the console (FormView.CommitLink). An
|
||||
enabled commit or a link names FormView.CommitDescribedBy, when set,
|
||||
in aria-describedby; a disabled commit keeps only its reason. */}}
|
||||
{{ define "formCommit" }}
|
||||
{{ if .CommitState }}{{ template "disabledControl" .CommitControl }}
|
||||
{{ else if .CommitLink }}<a href="{{ .CommitLink.URL }}" class="{{ .CommitClasses }}" target="_blank" rel="noopener">{{ .CommitLink.Label }}</a>
|
||||
{{ else if .CommitLink }}<a href="{{ .CommitLink.URL }}" class="{{ .CommitClasses }}" target="_blank" rel="noopener"{{ with .CommitDescribedBy }} aria-describedby="{{ . }}"{{ end }}>{{ .CommitLink.Label }}</a>
|
||||
{{ else }}
|
||||
<button type="submit" class="{{ .CommitClasses }}"><span class="spinner-border spinner-border-sm htmx-indicator me-1" aria-hidden="true"></span>{{ .Commit }}</button>
|
||||
<button type="submit" class="{{ .CommitClasses }}"{{ with .CommitDescribedBy }} aria-describedby="{{ . }}"{{ end }}><span class="spinner-border spinner-border-sm htmx-indicator me-1" aria-hidden="true"></span>{{ .Commit }}</button>
|
||||
{{ end }}
|
||||
{{ with .WayOut }}{{ if .Shown }}
|
||||
{{ if eq .Kind "link" }}{{ if .HXTarget }}<button type="button" class="btn btn-outline-secondary{{ if $.Dense }} btn-sm{{ end }}" hx-get="{{ .URL }}" hx-target="{{ .HXTarget }}" hx-swap="{{ if .HXSwap }}{{ .HXSwap }}{{ else }}innerHTML{{ end }}">{{ .Label }}</button>{{ else }}<a href="{{ .URL }}" class="btn btn-outline-secondary{{ if $.Dense }} btn-sm{{ end }}">{{ .Label }}</a>{{ end }}
|
||||
|
||||
@@ -21,8 +21,10 @@
|
||||
opposed to untoggleable"). A link marked Filled is the section's
|
||||
one filled call to action, which on a list page is its "New ..."
|
||||
link to the record's create page (design D20); every other link
|
||||
navigates and is outline-secondary. */}}
|
||||
{{ if .Action }}{{ if .Action.Toggle }}<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="collapse" data-bs-target="#{{ .Action.Toggle }}" aria-controls="{{ .Action.Toggle }}" aria-expanded="{{ if .Action.Expanded }}true{{ else }}false{{ end }}">{{ template "openerIcon" }}{{ .Action.Label }}</button>{{ else if .Action.Post }}<button type="button" class="btn btn-sm btn-outline-secondary" hx-post="{{ .Action.Post }}" hx-vals='{{ .Action.Vals }}' hx-include="{{ .Action.Include }}" hx-target="{{ .Action.Target }}" hx-swap="outerHTML" hx-sync="this:replace">{{ template "openerIcon" }}{{ .Action.Label }}</button>{{ else }}<a href="{{ .Action.URL }}" class="btn btn-sm {{ if .Action.Filled }}btn-primary{{ else }}btn-outline-secondary{{ end }}">{{ .Action.Label }}</a>{{ end }}{{ else if .Count }}<span class="text-body-secondary small">{{ .Count }}</span>{{ else if .Key }}<span class="text-body-secondary small">Key <code class="text-muted small">{{ .Key }}</code></span>{{ end }}
|
||||
navigates and is outline-secondary. A Refresh action reads the
|
||||
section's state again and swaps the section's body; it opens
|
||||
nothing, so it carries no glyph. */}}
|
||||
{{ if .Action }}{{ if .Action.Refresh }}<button type="button" class="btn btn-sm btn-outline-secondary" hx-post="{{ .Action.Refresh }}" hx-target="{{ .Action.Target }}" hx-swap="outerHTML" hx-sync="this:replace">{{ .Action.Label }}</button>{{ else if .Action.Toggle }}<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="collapse" data-bs-target="#{{ .Action.Toggle }}" aria-controls="{{ .Action.Toggle }}" aria-expanded="{{ if .Action.Expanded }}true{{ else }}false{{ end }}">{{ template "openerIcon" }}{{ .Action.Label }}</button>{{ else if .Action.Post }}<button type="button" class="btn btn-sm btn-outline-secondary" hx-post="{{ .Action.Post }}" hx-vals='{{ .Action.Vals }}' hx-include="{{ .Action.Include }}" hx-target="{{ .Action.Target }}" hx-swap="outerHTML" hx-sync="this:replace">{{ template "openerIcon" }}{{ .Action.Label }}</button>{{ else }}<a href="{{ .Action.URL }}" class="btn btn-sm {{ if .Action.Filled }}btn-primary{{ else }}btn-outline-secondary{{ end }}">{{ .Action.Label }}</a>{{ end }}{{ else if .Count }}<span class="text-body-secondary small">{{ .Count }}</span>{{ else if .Key }}<span class="text-body-secondary small">Key <code class="text-muted small">{{ .Key }}</code></span>{{ end }}
|
||||
</div>{{ end }}
|
||||
{{/* openerIcon is the leading glyph of every panel opener, on this part's
|
||||
Toggle actions and on the openers written out by hand (the
|
||||
|
||||
@@ -501,6 +501,12 @@ type FormView struct {
|
||||
// new tab, so the console stays where the member left it. Nil renders
|
||||
// the ordinary commit; CommitState, when also set, wins.
|
||||
CommitLink *CommitLinkView
|
||||
// CommitDescribedBy is the id of an element outside the form that
|
||||
// states a fact about what committing does, such as who bills the
|
||||
// charge. The part writes it as aria-describedby on the enabled submit
|
||||
// button and on a CommitLink, never on a CommitState, whose disabled
|
||||
// control is described by its own reason. Empty writes nothing.
|
||||
CommitDescribedBy string
|
||||
// CommitHidden renders no commit row at all, neither the commit nor
|
||||
// the way out: a Preview form at rest has nothing to apply and nothing
|
||||
// to discard, so it shows only its fields until a choice produces a
|
||||
|
||||
@@ -39,6 +39,12 @@ const SetupBannerDismissed Key = "setup_banner_dismissed"
|
||||
// not name the shape.
|
||||
const StripeEnvironmentCheck Key = "stripe.environment_check"
|
||||
|
||||
// StripePublicDetails records the Stripe account's public details as the
|
||||
// last read found them: the business name, the statement descriptor and
|
||||
// the icon members meet in Stripe's pages (merchant-disclosure D3). A JSON
|
||||
// object whose fields belong to the Stripe package, like the one above.
|
||||
const StripePublicDetails Key = "stripe.public_details"
|
||||
|
||||
// settingKind names the JSON shape a key's value holds, so the accessors
|
||||
// can refuse a key that was never declared as their shape rather than
|
||||
// silently coercing whatever JSON happens to be stored under it.
|
||||
@@ -58,6 +64,7 @@ const (
|
||||
var registry = map[Key]settingKind{
|
||||
SetupBannerDismissed: kindBool,
|
||||
StripeEnvironmentCheck: kindJSON,
|
||||
StripePublicDetails: kindJSON,
|
||||
}
|
||||
|
||||
// Store is the typed accessor over core.instance_settings. Built from a
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package stripe
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// PublicDetailsRecord is what the console remembers about the Stripe
|
||||
// account's public details: the business name, the statement descriptor
|
||||
// and the icon members meet on Checkout, on receipts and on their card
|
||||
// statement (merchant-disclosure D3). It is stored as the JSON value of the
|
||||
// `stripe.public_details` instance setting.
|
||||
//
|
||||
// The shape lives here for the reason EnvironmentCheckRecord does: the read
|
||||
// activity (internal/integrations/stripe/workflows) writes it and the pages
|
||||
// (internal/server) read it, and this store is the package both import.
|
||||
//
|
||||
// The account id is never recorded, and neither is the legal name: a field
|
||||
// carries only whether it equals the legal name, which is the one fact the
|
||||
// provider page needs from it.
|
||||
type PublicDetailsRecord struct {
|
||||
KeyFingerprint string `json:"key_fingerprint"`
|
||||
ReadAt *time.Time `json:"read_at,omitempty"`
|
||||
// AccountRead reports whether the account itself answered. When it did
|
||||
// not, the name and the descriptor came from the fallback reads and the
|
||||
// icon was not read (merchant-disclosure D2).
|
||||
AccountRead bool `json:"account_read"`
|
||||
Name PublicDetail `json:"name"`
|
||||
Descriptor PublicDetail `json:"descriptor"`
|
||||
Icon PublicDetail `json:"icon"`
|
||||
}
|
||||
|
||||
// PublicDetail is one public detail's outcome. Value is set only in the
|
||||
// read state; for the icon, a read icon carries no value and the state
|
||||
// alone says whether one is set.
|
||||
type PublicDetail struct {
|
||||
State string `json:"state"`
|
||||
Value string `json:"value,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
LegalName bool `json:"legal_name,omitempty"`
|
||||
Permission string `json:"permission,omitempty"`
|
||||
}
|
||||
|
||||
// The four states a public detail can be in.
|
||||
const (
|
||||
// DetailRead is a value the read found.
|
||||
DetailRead = "read"
|
||||
// DetailUnset is a detail the account answered for and holds nothing:
|
||||
// the icon when none is uploaded, or an empty name or descriptor.
|
||||
DetailUnset = "unset"
|
||||
// DetailNotPermitted is a detail the key was refused; Permission names
|
||||
// what would allow it.
|
||||
DetailNotPermitted = "not_permitted"
|
||||
// DetailNotYet is a detail the fallback could not read because the
|
||||
// account holds nothing to read it from yet: no recurring price for
|
||||
// the invoice preview, or no charge for the statement descriptor.
|
||||
DetailNotYet = "not_yet"
|
||||
)
|
||||
|
||||
// The places a value can come from.
|
||||
const (
|
||||
SourceAccount = "account"
|
||||
SourceInvoicePreview = "invoice_preview"
|
||||
SourceLatestCharge = "latest_charge"
|
||||
)
|
||||
|
||||
// The permissions a not-permitted detail names, in Stripe's dashboard
|
||||
// wording for the restricted-key resource that would allow the read.
|
||||
const (
|
||||
PermissionAccounts = "Accounts Read"
|
||||
PermissionInvoices = "Invoices"
|
||||
PermissionCharges = "Charges"
|
||||
)
|
||||
|
||||
// IsRead reports whether the detail holds a value the read found.
|
||||
func (d PublicDetail) IsRead() bool { return d.State == DetailRead }
|
||||
|
||||
// Current reports whether the record was read under the key in force. A
|
||||
// record from another key describes another Stripe environment, and every
|
||||
// consumer treats it as no record (merchant-disclosure D3).
|
||||
func (r PublicDetailsRecord) Current(fingerprint string) bool {
|
||||
return r.ReadAt != nil && r.KeyFingerprint == fingerprint
|
||||
}
|
||||
|
||||
// DecodePublicDetailsRecord reads the stored setting value. found is what
|
||||
// the instance store answered for the key's row; a key with no row, or a
|
||||
// row whose bytes do not decode, returns the zero record and false.
|
||||
func DecodePublicDetailsRecord(raw json.RawMessage, found bool) (PublicDetailsRecord, bool) {
|
||||
if !found || len(raw) == 0 {
|
||||
return PublicDetailsRecord{}, false
|
||||
}
|
||||
var rec PublicDetailsRecord
|
||||
if err := json.Unmarshal(raw, &rec); err != nil {
|
||||
return PublicDetailsRecord{}, false
|
||||
}
|
||||
return rec, true
|
||||
}
|
||||
|
||||
// ComparisonKey is the form two names are compared in: NFKC-normalised,
|
||||
// case-folded, and stripped to letters, combining marks and digits, so
|
||||
// "Riverside Makers", "RIVERSIDE MAKERS" and "Riverside-Makers" share one
|
||||
// key (merchant-disclosure D4). Keys are compared whole, except for a
|
||||
// statement descriptor Stripe cut at its length limit, whose key may be
|
||||
// the start of the name's (billing_facts.go).
|
||||
func ComparisonKey(s string) string {
|
||||
folded := cases.Fold().String(norm.NFKC.String(s))
|
||||
var b strings.Builder
|
||||
for _, r := range folded {
|
||||
if unicode.IsLetter(r) || unicode.IsMark(r) || unicode.IsDigit(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package stripe
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestDecodePublicDetailsRecord pins the three answers a stored value can
|
||||
// give: no row, bytes that do not decode, and a record.
|
||||
func TestDecodePublicDetailsRecord(t *testing.T) {
|
||||
if _, ok := DecodePublicDetailsRecord(nil, false); ok {
|
||||
t.Error("a key with no row decoded as a record")
|
||||
}
|
||||
if _, ok := DecodePublicDetailsRecord(json.RawMessage(`{"name":`), true); ok {
|
||||
t.Error("unreadable bytes decoded as a record")
|
||||
}
|
||||
read := time.Date(2026, 9, 25, 15, 4, 0, 0, time.UTC)
|
||||
raw, err := json.Marshal(PublicDetailsRecord{
|
||||
KeyFingerprint: "abc",
|
||||
ReadAt: &read,
|
||||
AccountRead: true,
|
||||
Name: PublicDetail{State: DetailRead, Value: "Acme Holdings LLC", Source: SourceAccount, LegalName: true},
|
||||
Descriptor: PublicDetail{State: DetailRead, Value: "ACMEHOLDINGS", Source: SourceAccount, LegalName: true},
|
||||
Icon: PublicDetail{State: DetailUnset, Source: SourceAccount},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
rec, ok := DecodePublicDetailsRecord(raw, true)
|
||||
if !ok {
|
||||
t.Fatal("a valid record did not decode")
|
||||
}
|
||||
if rec.Name.Value != "Acme Holdings LLC" || !rec.Name.LegalName || rec.Icon.State != DetailUnset {
|
||||
t.Errorf("decoded record lost fields: %+v", rec)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublicDetailsRecordCurrent pins the fingerprint rule: a record read
|
||||
// under another key, or one that never finished, is no record.
|
||||
func TestPublicDetailsRecordCurrent(t *testing.T) {
|
||||
read := time.Date(2026, 9, 25, 15, 4, 0, 0, time.UTC)
|
||||
rec := PublicDetailsRecord{KeyFingerprint: "abc", ReadAt: &read}
|
||||
if !rec.Current("abc") {
|
||||
t.Error("a record read under the key in force is not current")
|
||||
}
|
||||
if rec.Current("def") {
|
||||
t.Error("a record read under another key is current")
|
||||
}
|
||||
if (PublicDetailsRecord{KeyFingerprint: "abc"}).Current("abc") {
|
||||
t.Error("a record with no read time is current")
|
||||
}
|
||||
}
|
||||
|
||||
// TestComparisonKey pins the normalisation both surfaces compare names in
|
||||
// (merchant-disclosure D4): case, spacing, punctuation and compatibility
|
||||
// forms fall away; letters and digits stay; nothing is matched in part.
|
||||
func TestComparisonKey(t *testing.T) {
|
||||
cases := []struct{ a, b string }{
|
||||
{"Riverside Makers", "RIVERSIDE MAKERS"},
|
||||
{"Riverside Makers", "Riverside-Makers."},
|
||||
{"Straße Café", "STRASSE CAFÉ"},
|
||||
{"Riverside", "riverside"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if ComparisonKey(c.a) != ComparisonKey(c.b) {
|
||||
t.Errorf("ComparisonKey(%q) = %q, ComparisonKey(%q) = %q; want equal",
|
||||
c.a, ComparisonKey(c.a), c.b, ComparisonKey(c.b))
|
||||
}
|
||||
}
|
||||
if ComparisonKey("Riverside Makers") == ComparisonKey("Riverside") {
|
||||
t.Error("a name compared equal to part of itself")
|
||||
}
|
||||
if ComparisonKey("Acme 2") == ComparisonKey("Acme") {
|
||||
t.Error("digits were dropped")
|
||||
}
|
||||
}
|
||||
@@ -116,11 +116,13 @@ func (Adapter) MigrationSource() db.MigrationSource {
|
||||
// Stripe" block that used to live in internal/config/validate.go. The mode
|
||||
// is not declared: ModeForKey derives it from the API key's prefix (the
|
||||
// retired stripe-mode key could disagree with the key in force). No
|
||||
// account id is declared or read: reading it (GET /v1/account) needs
|
||||
// Stripe's Connect "Accounts Read" permission on a restricted key, which
|
||||
// is more than the console should hold, and a declared id could disagree
|
||||
// with the key with nothing to check it against. Dashboard links stay
|
||||
// unscoped and resolve in the operator's own dashboard session.
|
||||
// account id is declared or stored: a declared id could disagree with the
|
||||
// key with nothing to check it against. The account is read (GET
|
||||
// /v1/account) only for the public details members see, and only when the
|
||||
// key holds Accounts Read, an optional permission; without it the read
|
||||
// falls back to what the key can already see (merchant-disclosure D2).
|
||||
// Dashboard links stay unscoped and resolve in the operator's own
|
||||
// dashboard session.
|
||||
func (Adapter) ConfigSpec() []config.ConfigKey {
|
||||
return []config.ConfigKey{
|
||||
{Name: "stripe-api-key", Secret: true, RequiredGroup: "Stripe", Usage: "Stripe API key"},
|
||||
@@ -173,8 +175,8 @@ func (Adapter) CSRFExemptPaths() []string {
|
||||
}
|
||||
|
||||
// RegisterWorkflows registers Stripe's Temporal workflows and activities
|
||||
// (webhook processing, outbox draining, the environment check) against the
|
||||
// shared worker.
|
||||
// (webhook processing, outbox draining, the environment check, the
|
||||
// public-details read) against the shared worker.
|
||||
//
|
||||
// The environment check's activity holds the mode and the fingerprint of
|
||||
// the key this process runs under, read here from the same viper key
|
||||
@@ -188,11 +190,13 @@ func (Adapter) RegisterWorkflows(w worker.Worker, database *sql.DB, logger *slog
|
||||
w.RegisterWorkflow(workflows.PollIntegrationOutbox)
|
||||
w.RegisterWorkflow(workflows.ExecuteStripeOutboxEntry)
|
||||
w.RegisterWorkflow(workflows.StripeEnvironmentCheckWorkflow)
|
||||
w.RegisterWorkflow(workflows.StripePublicDetailsWorkflow)
|
||||
w.RegisterActivity(workflows.NewWebhookActivities(database, logger))
|
||||
w.RegisterActivity(workflows.NewOutboxActivities(database, logger))
|
||||
apiKey := viper.GetString("stripe-api-key")
|
||||
mode, _ := ModeForKey(apiKey)
|
||||
w.RegisterActivity(workflows.NewEnvironmentCheckActivities(database, logger, mode, FingerprintForKey(apiKey)))
|
||||
w.RegisterActivity(workflows.NewPublicDetailsActivities(database, logger, mode, FingerprintForKey(apiKey)))
|
||||
}
|
||||
|
||||
// Startup sets the stripe-go package-level API key — the single place this
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/instance"
|
||||
internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
|
||||
stripego "github.com/stripe/stripe-go/v81"
|
||||
"github.com/stripe/stripe-go/v81/account"
|
||||
"github.com/stripe/stripe-go/v81/charge"
|
||||
"github.com/stripe/stripe-go/v81/invoice"
|
||||
"github.com/stripe/stripe-go/v81/price"
|
||||
"go.temporal.io/sdk/client"
|
||||
"go.temporal.io/sdk/temporal"
|
||||
"go.temporal.io/sdk/workflow"
|
||||
)
|
||||
|
||||
// PublicDetailsWorkflowID is the fixed id the read runs under, so a second
|
||||
// start while one runs is refused by Temporal (merchant-disclosure D1).
|
||||
// Boot and the provider page's control start the same id.
|
||||
const PublicDetailsWorkflowID = "stripe-public-details"
|
||||
|
||||
// The two triggers a read is started by: every boot with Stripe
|
||||
// configured, and an operator pressing the provider page's control.
|
||||
const (
|
||||
PublicDetailsTriggerBoot = "boot"
|
||||
PublicDetailsTriggerOperator = "operator"
|
||||
)
|
||||
|
||||
// The activity's budget. The read is at most four calls, so a minute is
|
||||
// generous for one attempt; the retry budget covers a Stripe outage of a
|
||||
// few minutes, after which the previous record stays and the next boot or
|
||||
// press tries again.
|
||||
const (
|
||||
publicDetailsAttemptTimeout = time.Minute
|
||||
publicDetailsRetryBudget = 15 * time.Minute
|
||||
)
|
||||
|
||||
// PublicDetailsInput carries the trigger into the workflow and its activity.
|
||||
type PublicDetailsInput struct {
|
||||
Trigger string `json:"trigger"`
|
||||
}
|
||||
|
||||
// StripePublicDetailsWorkflow reads the Stripe account's public details:
|
||||
// the business name, the statement descriptor and the icon members meet in
|
||||
// Stripe's pages (merchant-disclosure D1, D2). One activity does the whole
|
||||
// read and writes the record the pages render.
|
||||
func StripePublicDetailsWorkflow(ctx workflow.Context, input PublicDetailsInput) (internalstripe.PublicDetailsRecord, error) {
|
||||
var acts *PublicDetailsActivities
|
||||
actCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
|
||||
StartToCloseTimeout: publicDetailsAttemptTimeout,
|
||||
ScheduleToCloseTimeout: publicDetailsRetryBudget,
|
||||
RetryPolicy: &temporal.RetryPolicy{
|
||||
InitialInterval: 5 * time.Second,
|
||||
BackoffCoefficient: 2.0,
|
||||
MaximumInterval: 2 * time.Minute,
|
||||
},
|
||||
})
|
||||
var rec internalstripe.PublicDetailsRecord
|
||||
if err := workflow.ExecuteActivity(actCtx, acts.ReadPublicDetails, input).Get(ctx, &rec); err != nil {
|
||||
workflow.GetLogger(ctx).Error("stripe public details read exhausted its retries",
|
||||
"trigger", input.Trigger, "error", err)
|
||||
return internalstripe.PublicDetailsRecord{}, err
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// StartPublicDetailsRead starts the read from outside a workflow: boot and
|
||||
// the provider page's control. Like StartEnvironmentCheck it carries no
|
||||
// conflict policy, so a start issued while a run holds the id is refused
|
||||
// and the caller learns it from the error.
|
||||
func StartPublicDetailsRead(ctx context.Context, c client.Client, taskQueue, trigger string) (client.WorkflowRun, error) {
|
||||
return c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
|
||||
ID: PublicDetailsWorkflowID,
|
||||
TaskQueue: taskQueue,
|
||||
}, StripePublicDetailsWorkflow, PublicDetailsInput{Trigger: trigger})
|
||||
}
|
||||
|
||||
// PublicDetailsActivities holds what the read needs: the database the
|
||||
// record lives in, a logger, and the mode and fingerprint of the key the
|
||||
// process holds, passed in for the reason EnvironmentCheckActivities takes
|
||||
// them.
|
||||
type PublicDetailsActivities struct {
|
||||
DB *sql.DB
|
||||
Logger *slog.Logger
|
||||
Mode string
|
||||
Fingerprint string
|
||||
}
|
||||
|
||||
// NewPublicDetailsActivities builds the read's activity set.
|
||||
func NewPublicDetailsActivities(db *sql.DB, logger *slog.Logger, mode, fingerprint string) *PublicDetailsActivities {
|
||||
return &PublicDetailsActivities{DB: db, Logger: logger, Mode: mode, Fingerprint: fingerprint}
|
||||
}
|
||||
|
||||
// ReadPublicDetails reads the account's public details with whatever the
|
||||
// key allows (merchant-disclosure D2) and records them.
|
||||
//
|
||||
// The account comes first. When it answers, it holds all three details
|
||||
// and the legal name to compare them with. When it answers 403, the key
|
||||
// lacks Accounts Read, and each detail is read another way on its own: the
|
||||
// name from an invoice preview, the descriptor from the latest charge, the
|
||||
// icon not at all. A 403 or an empty answer on a fallback read is recorded
|
||||
// against its detail and the run goes on; any other error fails the
|
||||
// activity so Temporal retries it, and the stored record stays as it was.
|
||||
func (a *PublicDetailsActivities) ReadPublicDetails(ctx context.Context, input PublicDetailsInput) (internalstripe.PublicDetailsRecord, error) {
|
||||
rec := internalstripe.PublicDetailsRecord{KeyFingerprint: a.Fingerprint}
|
||||
|
||||
acct, err := account.Get()
|
||||
switch {
|
||||
case err == nil:
|
||||
rec.AccountRead = true
|
||||
a.fromAccount(&rec, acct)
|
||||
case isPermissionDenied(err):
|
||||
if err := a.fromFallback(&rec); err != nil {
|
||||
return internalstripe.PublicDetailsRecord{}, err
|
||||
}
|
||||
default:
|
||||
return internalstripe.PublicDetailsRecord{}, fmt.Errorf("stripe get account: %w", err)
|
||||
}
|
||||
|
||||
read := time.Now().UTC()
|
||||
rec.ReadAt = &read
|
||||
// updatedBy is empty: no operator wrote this, the read did.
|
||||
if err := instance.NewStore(a.DB).SetJSON(ctx, instance.StripePublicDetails, rec, ""); err != nil {
|
||||
return internalstripe.PublicDetailsRecord{}, fmt.Errorf("record the public details: %w", err)
|
||||
}
|
||||
a.Logger.Info("stripe: public details read",
|
||||
slog.Bool("account_read", rec.AccountRead),
|
||||
slog.String("name", rec.Name.State),
|
||||
slog.String("descriptor", rec.Descriptor.State),
|
||||
slog.String("icon", rec.Icon.State),
|
||||
slog.String("mode", a.Mode),
|
||||
slog.String("key_fingerprint", internalstripe.FingerprintPrefix(a.Fingerprint)),
|
||||
slog.String("trigger", input.Trigger))
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// fromAccount fills the record from the account object. The legal name is
|
||||
// compared here and dropped: the record keeps only whether each detail
|
||||
// equals it.
|
||||
func (a *PublicDetailsActivities) fromAccount(rec *internalstripe.PublicDetailsRecord, acct *stripego.Account) {
|
||||
legal := internalstripe.ComparisonKey(legalName(acct))
|
||||
|
||||
var name string
|
||||
if acct.BusinessProfile != nil {
|
||||
name = acct.BusinessProfile.Name
|
||||
}
|
||||
rec.Name = accountDetail(name, legal)
|
||||
|
||||
var descriptor string
|
||||
var iconSet bool
|
||||
if acct.Settings != nil {
|
||||
if acct.Settings.Payments != nil {
|
||||
descriptor = acct.Settings.Payments.StatementDescriptor
|
||||
}
|
||||
if acct.Settings.Branding != nil {
|
||||
iconSet = acct.Settings.Branding.Icon != nil && acct.Settings.Branding.Icon.ID != ""
|
||||
}
|
||||
}
|
||||
rec.Descriptor = accountDetail(descriptor, legal)
|
||||
|
||||
rec.Icon = internalstripe.PublicDetail{State: internalstripe.DetailUnset, Source: internalstripe.SourceAccount}
|
||||
if iconSet {
|
||||
rec.Icon.State = internalstripe.DetailRead
|
||||
}
|
||||
}
|
||||
|
||||
// accountDetail is one detail read from the account: read when it holds
|
||||
// text, unset when it holds none, and marked as the legal name when its
|
||||
// comparison key equals the legal name's. An account that returned no
|
||||
// legal name marks nothing.
|
||||
func accountDetail(value, legalKey string) internalstripe.PublicDetail {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return internalstripe.PublicDetail{State: internalstripe.DetailUnset, Source: internalstripe.SourceAccount}
|
||||
}
|
||||
return internalstripe.PublicDetail{
|
||||
State: internalstripe.DetailRead,
|
||||
Value: value,
|
||||
Source: internalstripe.SourceAccount,
|
||||
LegalName: legalKey != "" && internalstripe.ComparisonKey(value) == legalKey,
|
||||
}
|
||||
}
|
||||
|
||||
// legalName is the registered name the account answered with: the
|
||||
// company's for a company account, the person's for an individual one.
|
||||
// Stripe returns neither for some account types, and then there is
|
||||
// nothing to compare against.
|
||||
func legalName(acct *stripego.Account) string {
|
||||
if acct.Company != nil && strings.TrimSpace(acct.Company.Name) != "" {
|
||||
return acct.Company.Name
|
||||
}
|
||||
if acct.Individual != nil {
|
||||
return strings.TrimSpace(acct.Individual.FirstName + " " + acct.Individual.LastName)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// fromFallback reads the name and the descriptor without the account. The
|
||||
// icon needs the account and is recorded as not permitted.
|
||||
func (a *PublicDetailsActivities) fromFallback(rec *internalstripe.PublicDetailsRecord) error {
|
||||
rec.Icon = internalstripe.PublicDetail{
|
||||
State: internalstripe.DetailNotPermitted,
|
||||
Permission: internalstripe.PermissionAccounts,
|
||||
}
|
||||
|
||||
name, err := previewName()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rec.Name = name
|
||||
|
||||
descriptor, err := latestChargeDescriptor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rec.Descriptor = descriptor
|
||||
return nil
|
||||
}
|
||||
|
||||
// previewName reads the name Checkout and receipts carry from an invoice
|
||||
// preview. The preview needs a recurring price and no customer, and it
|
||||
// creates nothing; any active recurring price of an active product serves,
|
||||
// because the name belongs to the account, not to the price. A price stays
|
||||
// active when its product is archived, and Stripe refuses a preview of one
|
||||
// ("the product is marked as inactive"), so the product is read with it.
|
||||
func previewName() (internalstripe.PublicDetail, error) {
|
||||
notPermitted := internalstripe.PublicDetail{
|
||||
State: internalstripe.DetailNotPermitted,
|
||||
Permission: internalstripe.PermissionInvoices,
|
||||
}
|
||||
|
||||
params := &stripego.PriceListParams{Active: stripego.Bool(true), Type: stripego.String("recurring")}
|
||||
params.Limit = stripego.Int64(100)
|
||||
params.Single = true
|
||||
params.AddExpand("data.product")
|
||||
it := price.List(params)
|
||||
var priceID string
|
||||
for priceID == "" && it.Next() {
|
||||
if p := it.Price(); p.Product != nil && p.Product.Active {
|
||||
priceID = p.ID
|
||||
}
|
||||
}
|
||||
if err := it.Err(); err != nil {
|
||||
if isPermissionDenied(err) {
|
||||
return notPermitted, nil
|
||||
}
|
||||
return internalstripe.PublicDetail{}, fmt.Errorf("stripe list recurring prices: %w", err)
|
||||
}
|
||||
if priceID == "" {
|
||||
return internalstripe.PublicDetail{State: internalstripe.DetailNotYet}, nil
|
||||
}
|
||||
|
||||
preview, err := invoice.CreatePreview(&stripego.InvoiceCreatePreviewParams{
|
||||
SubscriptionDetails: &stripego.InvoiceCreatePreviewSubscriptionDetailsParams{
|
||||
Items: []*stripego.InvoiceCreatePreviewSubscriptionDetailsItemParams{{Price: stripego.String(priceID)}},
|
||||
},
|
||||
})
|
||||
switch {
|
||||
case isPermissionDenied(err):
|
||||
return notPermitted, nil
|
||||
case err != nil:
|
||||
return internalstripe.PublicDetail{}, fmt.Errorf("stripe invoice preview: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(preview.AccountName) == "" {
|
||||
return internalstripe.PublicDetail{State: internalstripe.DetailUnset, Source: internalstripe.SourceInvoicePreview}, nil
|
||||
}
|
||||
return internalstripe.PublicDetail{
|
||||
State: internalstripe.DetailRead,
|
||||
Value: strings.TrimSpace(preview.AccountName),
|
||||
Source: internalstripe.SourceInvoicePreview,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// latestChargeDescriptor reads the statement text of the most recent
|
||||
// charge, the one fact about the card statement a key without Accounts Read
|
||||
// can see, and only once a payment exists.
|
||||
func latestChargeDescriptor() (internalstripe.PublicDetail, error) {
|
||||
params := &stripego.ChargeListParams{}
|
||||
params.Limit = stripego.Int64(1)
|
||||
params.Single = true
|
||||
it := charge.List(params)
|
||||
var descriptor string
|
||||
found := false
|
||||
if it.Next() {
|
||||
found = true
|
||||
descriptor = strings.TrimSpace(it.Charge().CalculatedStatementDescriptor)
|
||||
}
|
||||
if err := it.Err(); err != nil {
|
||||
if isPermissionDenied(err) {
|
||||
return internalstripe.PublicDetail{
|
||||
State: internalstripe.DetailNotPermitted,
|
||||
Permission: internalstripe.PermissionCharges,
|
||||
}, nil
|
||||
}
|
||||
return internalstripe.PublicDetail{}, fmt.Errorf("stripe list charges: %w", err)
|
||||
}
|
||||
if !found || descriptor == "" {
|
||||
return internalstripe.PublicDetail{State: internalstripe.DetailNotYet}, nil
|
||||
}
|
||||
return internalstripe.PublicDetail{
|
||||
State: internalstripe.DetailRead,
|
||||
Value: descriptor,
|
||||
Source: internalstripe.SourceLatestCharge,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// isPermissionDenied reports whether Stripe refused the key the permission
|
||||
// an endpoint needs. Only HTTP 403 counts: a revoked key answers 401, and
|
||||
// that is a fault to retry and log, not a permission to name.
|
||||
func isPermissionDenied(err error) bool {
|
||||
var stripeErr *stripego.Error
|
||||
if !errors.As(err, &stripeErr) {
|
||||
return false
|
||||
}
|
||||
return stripeErr.HTTPStatusCode == http.StatusForbidden
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package workflows_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/instance"
|
||||
internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
|
||||
stripewf "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/workflows"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/stripetest"
|
||||
stripego "github.com/stripe/stripe-go/v81"
|
||||
)
|
||||
|
||||
// The public-details read writes one instance setting and nothing else, so
|
||||
// each test clears that setting before and after itself and asserts on the
|
||||
// record the run left (merchant-disclosure D2, D3).
|
||||
|
||||
func publicDetailsActivities(database *sql.DB) *stripewf.PublicDetailsActivities {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||
return stripewf.NewPublicDetailsActivities(database, logger, internalstripe.ModeTest, "fingerprint-under-test")
|
||||
}
|
||||
|
||||
func clearPublicDetails(t *testing.T, database *sql.DB) {
|
||||
t.Helper()
|
||||
clear := func() {
|
||||
if _, err := database.Exec(`DELETE FROM core.instance_settings WHERE key = $1`,
|
||||
string(instance.StripePublicDetails)); err != nil {
|
||||
t.Fatalf("clear the public details record: %v", err)
|
||||
}
|
||||
}
|
||||
clear()
|
||||
t.Cleanup(clear)
|
||||
}
|
||||
|
||||
func readPublicDetails(t *testing.T, ctx context.Context, database *sql.DB) (internalstripe.PublicDetailsRecord, bool) {
|
||||
t.Helper()
|
||||
raw, found, err := instance.NewStore(database).GetJSON(ctx, instance.StripePublicDetails)
|
||||
if err != nil {
|
||||
t.Fatalf("read the public details record: %v", err)
|
||||
}
|
||||
return internalstripe.DecodePublicDetailsRecord(raw, found)
|
||||
}
|
||||
|
||||
func readPublic(t *testing.T, backend *stripetest.MockBackend) (internalstripe.PublicDetailsRecord, error) {
|
||||
t.Helper()
|
||||
database := testDB(t)
|
||||
clearPublicDetails(t, database)
|
||||
t.Cleanup(stripetest.Install(backend))
|
||||
return publicDetailsActivities(database).ReadPublicDetails(context.Background(),
|
||||
stripewf.PublicDetailsInput{Trigger: stripewf.PublicDetailsTriggerOperator})
|
||||
}
|
||||
|
||||
// TestPublicDetailsFromTheAccount: a key that may read the account records
|
||||
// all three details from it, and marks the name and the descriptor that
|
||||
// equal the company's legal name. The legal name itself is not kept.
|
||||
func TestPublicDetailsFromTheAccount(t *testing.T) {
|
||||
rec, err := readPublic(t, &stripetest.MockBackend{Account: &stripego.Account{
|
||||
BusinessProfile: &stripego.AccountBusinessProfile{Name: "Acme Holdings LLC"},
|
||||
Company: &stripego.AccountCompany{Name: "Acme Holdings LLC"},
|
||||
Settings: &stripego.AccountSettings{
|
||||
Payments: &stripego.AccountSettingsPayments{StatementDescriptor: "ACME HOLDINGS LLC"},
|
||||
Branding: &stripego.AccountSettingsBranding{},
|
||||
},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !rec.AccountRead {
|
||||
t.Error("account_read = false, want true when the account answered")
|
||||
}
|
||||
if rec.Name.Value != "Acme Holdings LLC" || rec.Name.Source != internalstripe.SourceAccount || !rec.Name.LegalName {
|
||||
t.Errorf("name = %+v, want the account's name marked as the legal name", rec.Name)
|
||||
}
|
||||
if rec.Descriptor.Value != "ACME HOLDINGS LLC" || !rec.Descriptor.LegalName {
|
||||
t.Errorf("descriptor = %+v, want the account's descriptor marked as the legal name", rec.Descriptor)
|
||||
}
|
||||
if rec.Icon.State != internalstripe.DetailUnset {
|
||||
t.Errorf("icon = %+v, want unset when no icon is uploaded", rec.Icon)
|
||||
}
|
||||
if rec.KeyFingerprint != "fingerprint-under-test" || rec.ReadAt == nil {
|
||||
t.Errorf("record = %+v, want the key's fingerprint and a read time", rec)
|
||||
}
|
||||
|
||||
stored, ok := readPublicDetails(t, context.Background(), testDB(t))
|
||||
if !ok || stored.Name.Value != "Acme Holdings LLC" {
|
||||
t.Fatalf("stored record = %+v (found %v), want the run's record", stored, ok)
|
||||
}
|
||||
raw, _ := json.Marshal(stored)
|
||||
if json.Valid(raw) && containsLegalNameField(raw) {
|
||||
t.Error("the stored record carries the legal name itself")
|
||||
}
|
||||
}
|
||||
|
||||
// containsLegalNameField reports whether the record's JSON holds a legal
|
||||
// name as a value rather than the per-detail comparison flag.
|
||||
func containsLegalNameField(raw []byte) bool {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, k := range []string{"legal_name", "company", "company_name"} {
|
||||
if _, ok := m[k]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestPublicDetailsAccountWithAChosenName: a public name the operator set
|
||||
// is not the legal name, and an uploaded icon reads as set.
|
||||
func TestPublicDetailsAccountWithAChosenName(t *testing.T) {
|
||||
rec, err := readPublic(t, &stripetest.MockBackend{Account: &stripego.Account{
|
||||
BusinessProfile: &stripego.AccountBusinessProfile{Name: "Riverside Makers"},
|
||||
Company: &stripego.AccountCompany{Name: "Acme Holdings LLC"},
|
||||
Settings: &stripego.AccountSettings{
|
||||
Payments: &stripego.AccountSettingsPayments{StatementDescriptor: "RIVERSIDE MAKERS"},
|
||||
Branding: &stripego.AccountSettingsBranding{Icon: &stripego.File{ID: "file_icon"}},
|
||||
},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if rec.Name.LegalName || rec.Descriptor.LegalName {
|
||||
t.Errorf("name %+v / descriptor %+v marked as the legal name", rec.Name, rec.Descriptor)
|
||||
}
|
||||
if rec.Icon.State != internalstripe.DetailRead {
|
||||
t.Errorf("icon = %+v, want read when an icon is uploaded", rec.Icon)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublicDetailsFallbackBeforeTheFirstPayment: a key without Accounts
|
||||
// Read gets the name from an invoice preview; with no charge yet, the
|
||||
// descriptor waits, and the icon names the permission it needs.
|
||||
func TestPublicDetailsFallbackBeforeTheFirstPayment(t *testing.T) {
|
||||
rec, err := readPublic(t, &stripetest.MockBackend{
|
||||
PriceList: []*stripego.Price{{ID: "price_recurring", Product: &stripego.Product{ID: "prod_live", Active: true}}},
|
||||
Invoice: &stripego.Invoice{AccountName: "Acme Holdings LLC"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if rec.AccountRead {
|
||||
t.Error("account_read = true, want false when the account refused")
|
||||
}
|
||||
if rec.Name.Value != "Acme Holdings LLC" || rec.Name.Source != internalstripe.SourceInvoicePreview {
|
||||
t.Errorf("name = %+v, want the preview's account name", rec.Name)
|
||||
}
|
||||
if rec.Descriptor.State != internalstripe.DetailNotYet {
|
||||
t.Errorf("descriptor = %+v, want not_yet before any charge", rec.Descriptor)
|
||||
}
|
||||
if rec.Icon.State != internalstripe.DetailNotPermitted || rec.Icon.Permission != internalstripe.PermissionAccounts {
|
||||
t.Errorf("icon = %+v, want not permitted naming Accounts Read", rec.Icon)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublicDetailsFallbackSkipsAnArchivedProductsPrice: a price stays
|
||||
// active when its product is archived, and Stripe refuses a preview of it,
|
||||
// so the fallback previews the first price whose product is active.
|
||||
func TestPublicDetailsFallbackSkipsAnArchivedProductsPrice(t *testing.T) {
|
||||
mock := &stripetest.MockBackend{
|
||||
PriceList: []*stripego.Price{
|
||||
{ID: "price_archived", Product: &stripego.Product{ID: "prod_archived", Active: false}},
|
||||
{ID: "price_live", Product: &stripego.Product{ID: "prod_live", Active: true}},
|
||||
},
|
||||
Invoice: &stripego.Invoice{AccountName: "Acme Holdings LLC"},
|
||||
}
|
||||
rec, err := readPublic(t, mock)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if rec.Name.Value != "Acme Holdings LLC" {
|
||||
t.Errorf("name = %+v, want the preview's account name", rec.Name)
|
||||
}
|
||||
previews := mock.CallsTo("POST", "/v1/invoices/create_preview")
|
||||
if len(previews) != 1 || previews[0].Form.Get("subscription_details[items][0][price]") != "price_live" {
|
||||
t.Errorf("previews = %+v, want one preview of price_live", previews)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublicDetailsFallbackReadsTheLatestCharge: once a payment exists,
|
||||
// the descriptor comes from it.
|
||||
func TestPublicDetailsFallbackReadsTheLatestCharge(t *testing.T) {
|
||||
rec, err := readPublic(t, &stripetest.MockBackend{
|
||||
PriceList: []*stripego.Price{{ID: "price_recurring", Product: &stripego.Product{ID: "prod_live", Active: true}}},
|
||||
Invoice: &stripego.Invoice{AccountName: "Acme Holdings LLC"},
|
||||
Charges: []*stripego.Charge{{CalculatedStatementDescriptor: "ACMEHOLDINGS"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if rec.Descriptor.Value != "ACMEHOLDINGS" || rec.Descriptor.Source != internalstripe.SourceLatestCharge {
|
||||
t.Errorf("descriptor = %+v, want the latest charge's statement text", rec.Descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublicDetailsRefusedFallbackReads: a 403 on the preview or on the
|
||||
// charge list is recorded against its detail and the run still succeeds.
|
||||
func TestPublicDetailsRefusedFallbackReads(t *testing.T) {
|
||||
rec, err := readPublic(t, &stripetest.MockBackend{
|
||||
PriceList: []*stripego.Price{{ID: "price_recurring", Product: &stripego.Product{ID: "prod_live", Active: true}}},
|
||||
PathErrs: map[string]error{
|
||||
"/v1/invoices/create_preview": stripetest.PermissionDenied("invoice_write"),
|
||||
"/v1/charges": stripetest.PermissionDenied("charge_read"),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("refused fallback reads must not fail the run: %v", err)
|
||||
}
|
||||
if rec.Name.State != internalstripe.DetailNotPermitted || rec.Name.Permission != internalstripe.PermissionInvoices {
|
||||
t.Errorf("name = %+v, want not permitted naming Invoices", rec.Name)
|
||||
}
|
||||
if rec.Descriptor.State != internalstripe.DetailNotPermitted || rec.Descriptor.Permission != internalstripe.PermissionCharges {
|
||||
t.Errorf("descriptor = %+v, want not permitted naming Charges", rec.Descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublicDetailsWithoutARecurringPrice: an account with nothing to
|
||||
// preview leaves the name waiting rather than failing.
|
||||
func TestPublicDetailsWithoutARecurringPrice(t *testing.T) {
|
||||
rec, err := readPublic(t, &stripetest.MockBackend{})
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if rec.Name.State != internalstripe.DetailNotYet {
|
||||
t.Errorf("name = %+v, want not_yet with no recurring price", rec.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublicDetailsFailureKeepsThePreviousRecord: anything but a 403 fails
|
||||
// the activity for Temporal to retry, and the record already stored stays.
|
||||
func TestPublicDetailsFailureKeepsThePreviousRecord(t *testing.T) {
|
||||
database := testDB(t)
|
||||
clearPublicDetails(t, database)
|
||||
ctx := context.Background()
|
||||
read := time.Date(2026, 9, 25, 15, 4, 0, 0, time.UTC)
|
||||
previous := internalstripe.PublicDetailsRecord{
|
||||
KeyFingerprint: "fingerprint-under-test",
|
||||
ReadAt: &read,
|
||||
Name: internalstripe.PublicDetail{State: internalstripe.DetailRead, Value: "Riverside Makers"},
|
||||
}
|
||||
if err := instance.NewStore(database).SetJSON(ctx, instance.StripePublicDetails, previous, ""); err != nil {
|
||||
t.Fatalf("seed the previous record: %v", err)
|
||||
}
|
||||
t.Cleanup(stripetest.Install(&stripetest.MockBackend{PathErrs: map[string]error{
|
||||
"/v1/account": &stripego.Error{Type: stripego.ErrorTypeAPI, HTTPStatusCode: 500},
|
||||
}}))
|
||||
|
||||
if _, err := publicDetailsActivities(database).ReadPublicDetails(ctx,
|
||||
stripewf.PublicDetailsInput{Trigger: stripewf.PublicDetailsTriggerBoot}); err == nil {
|
||||
t.Fatal("a 500 from the account must fail the activity")
|
||||
}
|
||||
stored, ok := readPublicDetails(t, ctx, database)
|
||||
if !ok || stored.Name.Value != "Riverside Makers" || !stored.ReadAt.Equal(read) {
|
||||
t.Errorf("stored record = %+v, want the previous record untouched", stored)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublicDetailsRevokedKeyIsAFault: a 401 is not a missing permission.
|
||||
func TestPublicDetailsRevokedKeyIsAFault(t *testing.T) {
|
||||
_, err := readPublic(t, &stripetest.MockBackend{PathErrs: map[string]error{
|
||||
"/v1/account": &stripego.Error{Type: stripego.ErrorTypeInvalidRequest, HTTPStatusCode: 401},
|
||||
}})
|
||||
if err == nil {
|
||||
t.Fatal("a 401 must fail the activity, not read as a missing permission")
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,8 @@ func init() {
|
||||
Note: "fired from the price row's Sync, the readiness panel's Sync to Stripe (labelled Create in live or Create in test when the recorded mapping is one the key cannot reach), and its Retry"},
|
||||
{Label: "Check now", Method: "POST", Path: "/partials/operator/integrations/stripe/environment-check",
|
||||
Note: "the Stripe provider page's Environment check section; starts the read-back boot starts, under the same workflow id"},
|
||||
{Label: "Read again", Method: "POST", Path: "/partials/operator/integrations/stripe/public-details",
|
||||
Note: "the Stripe provider page's Public details section; starts the read boot starts, under the same workflow id"},
|
||||
{Label: "Make default", Method: "POST", Path: "/partials/operator/products/{productID}/prices/{priceID}/make-default"},
|
||||
{Label: "Deactivate price", Method: "POST", Path: "/partials/operator/products/{productID}/prices/{priceID}/deactivate", Modal: true},
|
||||
|
||||
|
||||
@@ -53,6 +53,13 @@ type Link struct {
|
||||
Vals string
|
||||
Include string
|
||||
Target string
|
||||
// Refresh, when set, makes the action an htmx button that posts to it
|
||||
// and swaps Target, the section's own body, with the answer: the
|
||||
// control that reads the section's state again from where it lives
|
||||
// (the Stripe page's Check now and Read again). It renders tertiary
|
||||
// without the opener glyph, because it opens nothing; the section's
|
||||
// as-of line sits first in the body, under the heading.
|
||||
Refresh string
|
||||
}
|
||||
|
||||
// OperatorSurfaceRoot and MemberSurfaceRoot are the surfaceRoot template
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/instance"
|
||||
stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
|
||||
)
|
||||
|
||||
// Billing facts (merchant-disclosure D4, D5): the one or two sentences that
|
||||
// tell a member who bills them and what their card statement will say,
|
||||
// shown only when either would not be recognised against the deployment's
|
||||
// own name. The Products page, the dashboard's return from Stripe and the
|
||||
// Stripe provider page's "Shown to members" row all ask this file, so the
|
||||
// three can never disagree about what a member sees.
|
||||
|
||||
// BillingFacts is what the billingFacts partial renders: the sentences, and
|
||||
// the id the paragraph carries so a checkout control can name it in
|
||||
// aria-describedby. No sentences renders nothing.
|
||||
type BillingFacts struct {
|
||||
ID string
|
||||
Sentences []string
|
||||
}
|
||||
|
||||
// Shown reports whether the paragraph renders, which is also whether a
|
||||
// control that costs money should reference it.
|
||||
func (f BillingFacts) Shown() bool { return len(f.Sentences) > 0 }
|
||||
|
||||
// WithID returns the facts carrying the element id their section uses.
|
||||
func (f BillingFacts) WithID(id string) BillingFacts {
|
||||
f.ID = id
|
||||
return f
|
||||
}
|
||||
|
||||
// The element ids of the two Products page paragraphs, referenced by the
|
||||
// controls below them that cost money.
|
||||
const (
|
||||
plansBillingFactsID = "plans-billing-facts"
|
||||
addonsBillingFactsID = "addons-billing-facts"
|
||||
)
|
||||
|
||||
// trailingMemberConsole matches a deployment name ending in the whole words
|
||||
// "Member Console", separated from what precedes them by space or
|
||||
// punctuation, so "Riverside Makers Member Console" is recognised as
|
||||
// "Riverside Makers" too. It never strips the default name down to
|
||||
// nothing: the preceding part must hold a letter or a digit.
|
||||
var trailingMemberConsole = regexp.MustCompile(`(?i)^(.*?[\p{L}\p{N}].*?)[\s\p{P}]+member[\s\p{P}]*console[\s\p{P}]*$`)
|
||||
|
||||
// recognisedKeys are the comparison keys a billing name or statement text
|
||||
// is recognised against: the deployment name's own, and the name without a
|
||||
// trailing "Member Console" when that leaves something.
|
||||
func recognisedKeys(deploymentName string) map[string]bool {
|
||||
keys := map[string]bool{}
|
||||
if k := stripedb.ComparisonKey(deploymentName); k != "" {
|
||||
keys[k] = true
|
||||
}
|
||||
if m := trailingMemberConsole.FindStringSubmatch(strings.TrimSpace(deploymentName)); m != nil {
|
||||
if k := stripedb.ComparisonKey(m[1]); k != "" {
|
||||
keys[k] = true
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// statementDescriptorLimit is the most characters Stripe keeps in a
|
||||
// statement descriptor. A deployment whose name is longer cannot set it in
|
||||
// full, so a descriptor at the limit is read as the name cut short.
|
||||
const statementDescriptorLimit = 22
|
||||
|
||||
// nameRecognised reports whether a public business name would be
|
||||
// recognised: its key equals one of the deployment name's.
|
||||
func nameRecognised(name string, known map[string]bool) bool {
|
||||
return known[stripedb.ComparisonKey(name)]
|
||||
}
|
||||
|
||||
// descriptorRecognised reports whether a statement descriptor would be
|
||||
// recognised: its key equals one of the deployment name's, or the
|
||||
// descriptor is at Stripe's length limit and its key is the start of one
|
||||
// of them. That is the one partial match the rule makes, because Stripe
|
||||
// cut the descriptor, not the operator.
|
||||
func descriptorRecognised(descriptor string, known map[string]bool) bool {
|
||||
key := stripedb.ComparisonKey(descriptor)
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
if known[key] {
|
||||
return true
|
||||
}
|
||||
if utf8.RuneCountInString(descriptor) < statementDescriptorLimit {
|
||||
return false
|
||||
}
|
||||
for k := range known {
|
||||
if strings.HasPrefix(k, key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// billingFactsFor applies the rule to a record. It shows nothing for a
|
||||
// record that is not current under the key in force, and otherwise shows
|
||||
// each sentence whose value was read and is not recognised, on its own
|
||||
// condition.
|
||||
func billingFactsFor(rec stripedb.PublicDetailsRecord, found bool, fingerprint, deploymentName string) BillingFacts {
|
||||
if !found || !rec.Current(fingerprint) {
|
||||
return BillingFacts{}
|
||||
}
|
||||
known := recognisedKeys(deploymentName)
|
||||
var sentences []string
|
||||
if rec.Name.IsRead() && !nameRecognised(rec.Name.Value, known) {
|
||||
sentences = append(sentences, "Billed by "+rec.Name.Value+".")
|
||||
}
|
||||
if rec.Descriptor.IsRead() && !descriptorRecognised(rec.Descriptor.Value, known) {
|
||||
sentences = append(sentences, "Card statement: "+rec.Descriptor.Value+".")
|
||||
}
|
||||
return BillingFacts{Sentences: sentences}
|
||||
}
|
||||
|
||||
// loadPublicDetails reads the stored public-details record. A failed read
|
||||
// is logged and reads as no record, which shows members nothing new.
|
||||
func loadPublicDetails(ctx context.Context, db *sql.DB, logger *slog.Logger) (stripedb.PublicDetailsRecord, bool) {
|
||||
if db == nil {
|
||||
return stripedb.PublicDetailsRecord{}, false
|
||||
}
|
||||
raw, found, err := instance.NewStore(db).GetJSON(ctx, instance.StripePublicDetails)
|
||||
if err != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("public details: record read failed", slog.Any("error", err))
|
||||
}
|
||||
return stripedb.PublicDetailsRecord{}, false
|
||||
}
|
||||
return stripedb.DecodePublicDetailsRecord(raw, found)
|
||||
}
|
||||
|
||||
// loadBillingFacts is the member pages' one call: read the record, apply
|
||||
// the rule against the deployment's name. One instance-setting read, no
|
||||
// Stripe call. An empty fingerprint means Stripe is not configured, and a
|
||||
// deployment without billing shows nothing.
|
||||
func loadBillingFacts(ctx context.Context, db *sql.DB, fingerprint string, logger *slog.Logger) BillingFacts {
|
||||
if fingerprint == "" {
|
||||
return BillingFacts{}
|
||||
}
|
||||
rec, found := loadPublicDetails(ctx, db, logger)
|
||||
return billingFactsFor(rec, found, fingerprint, config.DeploymentName())
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
|
||||
)
|
||||
|
||||
// The billing line's placement (merchant-disclosure D5): once under each
|
||||
// purchase section's heading, referenced by that section's controls that
|
||||
// cost money and by nothing else, and repeated in the success return.
|
||||
|
||||
var shownFacts = []string{"Billed by Acme Holdings LLC.", "Card statement: ACMEHOLDINGS."}
|
||||
|
||||
func renderMember(t *testing.T, name string, data any) string {
|
||||
t.Helper()
|
||||
h, err := NewMemberProductsHandler(MemberProductsConfig{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))})
|
||||
if err != nil {
|
||||
t.Fatalf("new handler: %v", err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.Templates.Render(rec, name, data)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("render %s = %d: %s", name, rec.Code, rec.Body.String())
|
||||
}
|
||||
return rec.Body.String()
|
||||
}
|
||||
|
||||
func plansWithMoves(facts BillingFacts) PlansData {
|
||||
return PlansData{
|
||||
BillingFacts: facts,
|
||||
Ladders: []LadderViewModel{{
|
||||
Name: "Workshop access", Enrolled: true,
|
||||
Tiers: []TierViewModel{
|
||||
{Name: "Community", Relation: "current"},
|
||||
{Name: "Standard", ProductID: "p_std", Relation: "upgrade", PriceID: "price_std", Purchasable: true, MoveKind: "checkout", MoveEnabled: true, MoveLabel: "Upgrade"},
|
||||
{Name: "Plus", ProductID: "p_plus", Relation: "upgrade", PriceID: "price_plus", MoveLadderID: "l1", Purchasable: true, MoveKind: "switch", MoveEnabled: true, MoveLabel: "Upgrade"},
|
||||
{Name: "Free", ProductID: "p_free", Relation: "downgrade", MoveLadderID: "l1", MoveKind: "cancel", MoveEnabled: true, MoveLabel: "Downgrade"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlansCarryTheBillingLineOnceAndThePaidMovesReferenceIt(t *testing.T) {
|
||||
body := renderMember(t, "member_plans.html",
|
||||
plansWithMoves(BillingFacts{Sentences: shownFacts}.WithID(plansBillingFactsID)))
|
||||
|
||||
if n := strings.Count(body, `id="plans-billing-facts"`); n != 1 {
|
||||
t.Fatalf("plans billing line rendered %d times, want once: %s", n, body)
|
||||
}
|
||||
if !strings.Contains(body, "Billed by Acme Holdings LLC. Card statement: ACMEHOLDINGS.") {
|
||||
t.Errorf("the line does not carry both sentences: %s", body)
|
||||
}
|
||||
if strings.Index(body, `id="plans-billing-facts"`) > strings.Index(body, "Workshop access") {
|
||||
t.Error("the line renders below the first ladder instead of under the Plans heading")
|
||||
}
|
||||
if n := strings.Count(body, `aria-describedby="plans-billing-facts"`); n != 2 {
|
||||
t.Errorf("%d controls reference the line, want the checkout and the switch, not the cancel", n)
|
||||
}
|
||||
for _, control := range []string{`hx-post="/billing/checkout"`, `hx-get="/partials/member/plans/switch/preview"`} {
|
||||
tag := body[strings.Index(body, control):]
|
||||
tag = tag[:strings.Index(tag, ">")]
|
||||
if !strings.Contains(tag, `aria-describedby="plans-billing-facts"`) {
|
||||
t.Errorf("a paid move does not reference the line: %s", tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The switch preview's commit, Confirm switch or the Approve payment link,
|
||||
// is the control that commits the charge, so it references the line too.
|
||||
// WithPreview is the one path both preview renders take.
|
||||
func TestPlanPreviewCommitReferencesTheLine(t *testing.T) {
|
||||
values := forms.NewValues()
|
||||
values.Set("ladder_id", "l1")
|
||||
values.Set("price_id", "price_plus")
|
||||
preview := func(facts BillingFacts, link bool) string {
|
||||
t.Helper()
|
||||
view := forms.Render(planMoveForm, forms.Binding{Mode: forms.ModeRecord, Values: values,
|
||||
Message: template.HTML("<p>Your card is charged now.</p>")})
|
||||
if link {
|
||||
view.CommitLink = &forms.CommitLinkView{Label: "Approve payment", URL: "https://invoice.stripe.com/i/test"}
|
||||
}
|
||||
data := plansWithMoves(facts)
|
||||
data.WithPreview(&view)
|
||||
return renderMember(t, "member_plans.html", data)
|
||||
}
|
||||
|
||||
shown := BillingFacts{Sentences: shownFacts}.WithID(plansBillingFactsID)
|
||||
body := preview(shown, false)
|
||||
commit := body[strings.Index(body, `type="submit"`):]
|
||||
commit = commit[:strings.Index(commit, ">")]
|
||||
if !strings.Contains(commit, `aria-describedby="plans-billing-facts"`) {
|
||||
t.Errorf("Confirm switch does not reference the line: %s", commit)
|
||||
}
|
||||
body = preview(shown, true)
|
||||
link := body[strings.Index(body, `href="https://invoice.stripe.com/i/test"`):]
|
||||
link = link[:strings.Index(link, ">")]
|
||||
if !strings.Contains(link, `aria-describedby="plans-billing-facts"`) {
|
||||
t.Errorf("Approve payment does not reference the line: %s", link)
|
||||
}
|
||||
if body := preview(BillingFacts{}.WithID(plansBillingFactsID), false); strings.Contains(body, "plans-billing-facts") {
|
||||
t.Errorf("no line shown, yet something references it: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlansWithoutFactsRenderNothingNew(t *testing.T) {
|
||||
body := renderMember(t, "member_plans.html", plansWithMoves(BillingFacts{}.WithID(plansBillingFactsID)))
|
||||
if strings.Contains(body, "billing-facts") || strings.Contains(body, "Billed by") {
|
||||
t.Errorf("recognised names still rendered a line or a reference: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddonsCarryTheirOwnLine(t *testing.T) {
|
||||
data := AddonsData{
|
||||
HasPlans: true,
|
||||
BillingFacts: BillingFacts{Sentences: shownFacts[:1]}.WithID(addonsBillingFactsID),
|
||||
Addons: []AddonViewModel{
|
||||
{ProductID: "p_storage", Name: "Extra storage", PriceID: "price_storage"},
|
||||
{ProductID: "p_domain", Name: "Custom domain", PriceID: "price_domain"},
|
||||
},
|
||||
}
|
||||
body := renderMember(t, "member_addons.html", data)
|
||||
if n := strings.Count(body, `id="addons-billing-facts"`); n != 1 {
|
||||
t.Fatalf("add-ons line rendered %d times, want once: %s", n, body)
|
||||
}
|
||||
lead := strings.Index(body, "Products to add alongside your plan")
|
||||
line := strings.Index(body, `id="addons-billing-facts"`)
|
||||
table := strings.Index(body, "<table")
|
||||
if !(lead < line && line < table) {
|
||||
t.Errorf("the line is not between the lead sentence and the table (lead %d, line %d, table %d)", lead, line, table)
|
||||
}
|
||||
if n := strings.Count(body, `aria-describedby="addons-billing-facts"`); n != 2 {
|
||||
t.Errorf("%d Add controls reference the line, want both", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlansOfferPaidMoveGate(t *testing.T) {
|
||||
checkout := []LadderViewModel{{Tiers: []TierViewModel{{MoveKind: "checkout", MoveEnabled: true}}}}
|
||||
switches := []LadderViewModel{{Tiers: []TierViewModel{{MoveKind: "switch", MoveEnabled: true}, {MoveKind: "cancel", MoveEnabled: true}}}}
|
||||
disabled := []LadderViewModel{{Tiers: []TierViewModel{{MoveKind: "checkout", MoveEnabled: false}, {MoveKind: "switch", MoveEnabled: false}}}}
|
||||
cancels := []LadderViewModel{{Tiers: []TierViewModel{{MoveKind: "cancel", MoveEnabled: true}}}}
|
||||
if !plansOfferPaidMove(checkout) {
|
||||
t.Error("an enabled checkout does not earn the line")
|
||||
}
|
||||
if !plansOfferPaidMove(switches) {
|
||||
t.Error("an enabled switch does not earn the line")
|
||||
}
|
||||
if plansOfferPaidMove(disabled) || plansOfferPaidMove(cancels) || plansOfferPaidMove(nil) {
|
||||
t.Error("a region with no enabled paid move earned the line")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuccessReturnRepeatsTheFacts(t *testing.T) {
|
||||
body := renderIndex(t, IndexPageData{CheckoutStatus: "success", BillingFacts: BillingFacts{Sentences: shownFacts}})
|
||||
alert := body[strings.Index(body, "alert-success"):]
|
||||
alert = alert[:strings.Index(alert, "</div>\n")]
|
||||
if !strings.Contains(alert, "Billed by Acme Holdings LLC. Card statement: ACMEHOLDINGS.") {
|
||||
t.Errorf("the success alert does not repeat the facts: %s", alert)
|
||||
}
|
||||
if strings.Contains(alert, "text-muted") {
|
||||
t.Error("the facts inside the alert use muted text, which falls under the contrast floor there")
|
||||
}
|
||||
|
||||
plain := renderIndex(t, IndexPageData{CheckoutStatus: "success"})
|
||||
if strings.Contains(plain, "Billed by") {
|
||||
t.Error("a success return with recognised names still shows a billing line")
|
||||
}
|
||||
cancel := renderIndex(t, IndexPageData{CheckoutStatus: "cancel", BillingFacts: BillingFacts{Sentences: shownFacts}})
|
||||
if strings.Contains(cancel, "Billed by") {
|
||||
t.Error("a cancelled return shows the billing line")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
|
||||
)
|
||||
|
||||
// TestBillingFactsRule pins the recognition rule (merchant-disclosure D4)
|
||||
// row by row: what a member is told, from the deployment's name and the
|
||||
// record, and when they are told nothing.
|
||||
func TestBillingFactsRule(t *testing.T) {
|
||||
read := time.Date(2026, 9, 25, 15, 4, 0, 0, time.UTC)
|
||||
record := func(name, descriptor string) stripedb.PublicDetailsRecord {
|
||||
rec := stripedb.PublicDetailsRecord{KeyFingerprint: "fp", ReadAt: &read}
|
||||
if name != "" {
|
||||
rec.Name = stripedb.PublicDetail{State: stripedb.DetailRead, Value: name}
|
||||
} else {
|
||||
rec.Name = stripedb.PublicDetail{State: stripedb.DetailNotYet}
|
||||
}
|
||||
if descriptor != "" {
|
||||
rec.Descriptor = stripedb.PublicDetail{State: stripedb.DetailRead, Value: descriptor}
|
||||
} else {
|
||||
rec.Descriptor = stripedb.PublicDetail{State: stripedb.DetailNotYet}
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
label string
|
||||
deployment string
|
||||
rec stripedb.PublicDetailsRecord
|
||||
found bool
|
||||
fp string
|
||||
want []string
|
||||
}{
|
||||
{"names match", "Riverside Makers", record("Riverside Makers", "RIVERSIDE MAKERS"), true, "fp", nil},
|
||||
{"trailing Member Console is recognised", "Riverside Makers Member Console", record("Riverside Makers", "RIVERSIDE MAKERS"), true, "fp", nil},
|
||||
{"punctuation before Member Console", "Riverside Makers: member console", record("Riverside Makers", ""), true, "fp", nil},
|
||||
{"the default name recognises only itself", "Member Console", record("Acme Holdings LLC", ""), true, "fp",
|
||||
[]string{"Billed by Acme Holdings LLC."}},
|
||||
{"both differ", "Riverside Makers", record("Acme Holdings LLC", "ACMEHOLDINGS"), true, "fp",
|
||||
[]string{"Billed by Acme Holdings LLC.", "Card statement: ACMEHOLDINGS."}},
|
||||
{"only the descriptor differs", "Riverside Makers", record("Riverside Makers", "ACMEHOLDINGS"), true, "fp",
|
||||
[]string{"Card statement: ACMEHOLDINGS."}},
|
||||
{"only the name differs", "Riverside Makers", record("Acme Holdings LLC", "RIVERSIDEMAKERS"), true, "fp",
|
||||
[]string{"Billed by Acme Holdings LLC."}},
|
||||
{"an unknown descriptor adds nothing", "Riverside Makers", record("Acme Holdings LLC", ""), true, "fp",
|
||||
[]string{"Billed by Acme Holdings LLC."}},
|
||||
{"a near miss shows the line", "Riverside Makers", record("Riverside Makers Co", ""), true, "fp",
|
||||
[]string{"Billed by Riverside Makers Co."}},
|
||||
{"part of the deployment name is not recognised", "Riverside Makers", record("Riverside", ""), true, "fp",
|
||||
[]string{"Billed by Riverside."}},
|
||||
// Stripe keeps 22 characters of a descriptor, so a longer
|
||||
// deployment name can only be set cut short.
|
||||
{"a descriptor cut at Stripe's limit is recognised", "Riverside Makers Cooperative Guild", record("", "RIVERSIDE MAKERS COOPE"), true, "fp", nil},
|
||||
{"a cut descriptor that departs from the name shows the line", "Riverside Makers Cooperative Guild", record("", "RIVERSIDE MAKERS GUILD"), true, "fp",
|
||||
[]string{"Card statement: RIVERSIDE MAKERS GUILD."}},
|
||||
{"a start of the name under the limit is not recognised", "Riverside Makers Cooperative Guild", record("", "RIVERSIDE MAKERS"), true, "fp",
|
||||
[]string{"Card statement: RIVERSIDE MAKERS."}},
|
||||
{"a business name has no limit to be cut at", "Riverside Makers Cooperative Guild", record("Riverside Makers Coope", ""), true, "fp",
|
||||
[]string{"Billed by Riverside Makers Coope."}},
|
||||
{"a record under another key shows nothing", "Riverside Makers", record("Acme Holdings LLC", "ACMEHOLDINGS"), true, "other", nil},
|
||||
{"no record shows nothing", "Riverside Makers", stripedb.PublicDetailsRecord{}, false, "fp", nil},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.label, func(t *testing.T) {
|
||||
got := billingFactsFor(c.rec, c.found, c.fp, c.deployment).Sentences
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("sentences = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecognisedKeysStripOnlyWholeWords: "Member Console" is stripped as
|
||||
// trailing whole words, never from inside a word.
|
||||
func TestRecognisedKeysStripOnlyWholeWords(t *testing.T) {
|
||||
keys := recognisedKeys("Riverside MakersMember Console")
|
||||
if keys[stripedb.ComparisonKey("Riverside Makers")] {
|
||||
t.Error("stripped Member Console from inside a word")
|
||||
}
|
||||
keys = recognisedKeys("Member Console")
|
||||
if len(keys) != 1 || !keys["memberconsole"] {
|
||||
t.Errorf("the default name's keys = %v, want only itself", keys)
|
||||
}
|
||||
}
|
||||
@@ -115,4 +115,9 @@ type IndexPageData struct {
|
||||
// (member-dashboard ADDED requirement "The dashboard has one empty
|
||||
// state"; design D25).
|
||||
HasEntitlement bool
|
||||
// BillingFacts repeats, on a successful return from Stripe, the
|
||||
// sentences the Products page showed under the same rule: who billed
|
||||
// the member and what their card statement says (merchant-disclosure
|
||||
// D5). Empty on every other load.
|
||||
BillingFacts BillingFacts
|
||||
}
|
||||
|
||||
@@ -44,6 +44,10 @@ type MemberProductsHandler struct {
|
||||
// whether a plan is purchasable. May be nil (e.g. in tests or when Stripe
|
||||
// is not configured), in which case plans are treated as not purchasable.
|
||||
Database *sql.DB
|
||||
// StripeKeyFingerprint names the API key in force, so the billing facts
|
||||
// under each section read only a public-details record read under that
|
||||
// key (merchant-disclosure D3). Empty when Stripe is not configured.
|
||||
StripeKeyFingerprint string
|
||||
}
|
||||
|
||||
// MemberProductsConfig holds configuration for the member products handler.
|
||||
@@ -56,6 +60,8 @@ type MemberProductsConfig struct {
|
||||
// StripeMode is the environment derived from the API key at boot; see
|
||||
// MemberProductsHandler.StripeMode.
|
||||
StripeMode string
|
||||
// StripeKeyFingerprint: see MemberProductsHandler.StripeKeyFingerprint.
|
||||
StripeKeyFingerprint string
|
||||
}
|
||||
|
||||
// NewMemberProductsHandler creates a new MemberProductsHandler.
|
||||
@@ -84,6 +90,8 @@ func NewMemberProductsHandler(cfg MemberProductsConfig) (*MemberProductsHandler,
|
||||
Templates: NewSafeTemplates(tmpl, cfg.Logger),
|
||||
Database: cfg.Database,
|
||||
StripeMode: cfg.StripeMode,
|
||||
|
||||
StripeKeyFingerprint: cfg.StripeKeyFingerprint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -329,6 +337,22 @@ type PlansData struct {
|
||||
// estimate as its Message and Confirm / Keep-current as its commit and
|
||||
// way out (spec form-library; design D9, D10).
|
||||
Preview *forms.FormView
|
||||
// BillingFacts names who bills the member and what their card statement
|
||||
// says, under the Plans heading, when either would not be recognised and
|
||||
// at least one tier offers an enabled move that costs money
|
||||
// (merchant-disclosure D5). Those controls, checkouts and switches,
|
||||
// reference it in aria-describedby, and so does the preview's commit.
|
||||
BillingFacts BillingFacts
|
||||
}
|
||||
|
||||
// WithPreview sets the switch preview, and makes its commit, the control
|
||||
// that confirms the switch or leaves to approve its payment, reference the
|
||||
// billing line when the line is shown.
|
||||
func (d *PlansData) WithPreview(view *forms.FormView) {
|
||||
if d.BillingFacts.Shown() {
|
||||
view.CommitDescribedBy = d.BillingFacts.ID
|
||||
}
|
||||
d.Preview = view
|
||||
}
|
||||
|
||||
// planMoveMessage builds the switch confirmation's prose (plan-switch-timing
|
||||
@@ -403,6 +427,10 @@ type AddonsData struct {
|
||||
// plan-less deployment must not read its whole catalog as extras.
|
||||
HasPlans bool
|
||||
Error string
|
||||
// BillingFacts is the add-ons section's own paragraph, under the same
|
||||
// rule as the Plans one; every listed add-on leads to Stripe, so the
|
||||
// section's presence is the whole condition (merchant-disclosure D5).
|
||||
BillingFacts BillingFacts
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
@@ -792,9 +820,28 @@ func (h *MemberProductsHandler) buildPlansData(ctx context.Context, orgID string
|
||||
data.Ladders = append(data.Ladders, lvm)
|
||||
}
|
||||
|
||||
if plansOfferPaidMove(data.Ladders) {
|
||||
data.BillingFacts = loadBillingFacts(ctx, h.Database, h.StripeKeyFingerprint, h.Logger).WithID(plansBillingFactsID)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// plansOfferPaidMove reports whether any tier's move is enabled and costs
|
||||
// money: a checkout, or a switch, whose target is always a paid rung and
|
||||
// which charges the card on file now or at renewal. A cancel costs
|
||||
// nothing, and a disabled control leads nowhere, so neither earns the
|
||||
// Plans section a billing line.
|
||||
func plansOfferPaidMove(ladders []LadderViewModel) bool {
|
||||
for _, l := range ladders {
|
||||
for _, t := range l.Tiers {
|
||||
if t.MoveEnabled && (t.MoveKind == "checkout" || t.MoveKind == "switch") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetSwitchPreview handles GET /partials/member/plans/switch/preview — it renders
|
||||
// the plans partial with a confirmation banner carrying the proration estimate
|
||||
// for the proposed paid→paid switch. The estimate is best-effort; the banner
|
||||
@@ -886,7 +933,7 @@ func (h *MemberProductsHandler) renderSwitchPreview(w http.ResponseWriter, ctx c
|
||||
view := forms.Render(planMoveForm, binding)
|
||||
|
||||
data := h.buildPlansData(ctx, orgID)
|
||||
data.Preview = &view
|
||||
data.WithPreview(&view)
|
||||
if status != http.StatusOK {
|
||||
w.WriteHeader(status)
|
||||
}
|
||||
@@ -916,7 +963,7 @@ func (h *MemberProductsHandler) renderApprovalPreview(w http.ResponseWriter, ctx
|
||||
view.CommitLink = &forms.CommitLinkView{Label: "Approve payment", URL: approval.HostedInvoiceURL}
|
||||
|
||||
data := h.buildPlansData(ctx, orgID)
|
||||
data.Preview = &view
|
||||
data.WithPreview(&view)
|
||||
h.Templates.Render(w, "member_plans.html", data)
|
||||
}
|
||||
|
||||
@@ -1484,6 +1531,9 @@ func (h *MemberProductsHandler) buildAddonsData(ctx context.Context) AddonsData
|
||||
})
|
||||
}
|
||||
|
||||
if len(data.Addons) > 0 {
|
||||
data.BillingFacts = loadBillingFacts(ctx, h.Database, h.StripeKeyFingerprint, h.Logger).WithID(addonsBillingFactsID)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,10 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/instance"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
|
||||
stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
|
||||
@@ -72,6 +74,34 @@ type StripeIntegrationData struct {
|
||||
// there is no key to read anything back under otherwise.
|
||||
EnvironmentCheckHeader SectionHeader
|
||||
EnvironmentCheck EnvironmentCheck
|
||||
|
||||
// PublicDetailsHeader/PublicDetails carry the "Public details" section
|
||||
// (merchant-disclosure D6): the business name, statement descriptor and
|
||||
// icon members meet in Stripe's pages, as the last read found them, and
|
||||
// the header's control that reads them again. Rendered only when Stripe
|
||||
// is configured, like the Environment check.
|
||||
PublicDetailsHeader SectionHeader
|
||||
PublicDetails PublicDetailsView
|
||||
}
|
||||
|
||||
// PublicDetailsView is the Public details section's state: one resting
|
||||
// line, and, when a current record exists, one row per detail and what
|
||||
// members are shown because of them.
|
||||
type PublicDetailsView struct {
|
||||
Line string
|
||||
Rows []PublicDetailRow
|
||||
Shown string
|
||||
}
|
||||
|
||||
// PublicDetailRow is one detail. A read detail carries Value, and Notes
|
||||
// after it: that it equals the legal name, and where it was read from.
|
||||
// Reason stands in the value cell of a detail that is unset or that the
|
||||
// read could not reach, saying which.
|
||||
type PublicDetailRow struct {
|
||||
Label string
|
||||
Value string
|
||||
Notes []string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// EnvironmentCheck is the Environment check section's state: one resting
|
||||
@@ -397,6 +427,105 @@ func countMappedStripeIDs(ctx context.Context, db *sql.DB, unverifiedOnly bool,
|
||||
return n
|
||||
}
|
||||
|
||||
// publicDetailsNeverRead is the line for a deployment where no read has
|
||||
// finished under any key.
|
||||
const publicDetailsNeverRead = "Not read yet."
|
||||
|
||||
// loadPublicDetailsView builds the Public details section from the stored
|
||||
// record (merchant-disclosure D6). A record read under another key shows no
|
||||
// rows, only that the key changed: its values describe another Stripe
|
||||
// environment. The Shown to members row asks billing_facts.go, so it names
|
||||
// exactly what members see.
|
||||
func loadPublicDetailsView(ctx context.Context, db *sql.DB, fingerprint string, logger *slog.Logger) PublicDetailsView {
|
||||
rec, found := loadPublicDetails(ctx, db, logger)
|
||||
return publicDetailsViewFor(rec, found, fingerprint, config.DeploymentName())
|
||||
}
|
||||
|
||||
// publicDetailsViewFor is loadPublicDetailsView without the read, so each
|
||||
// state can be pinned without a database.
|
||||
func publicDetailsViewFor(rec stripedb.PublicDetailsRecord, found bool, fingerprint, deploymentName string) PublicDetailsView {
|
||||
if !found || rec.ReadAt == nil {
|
||||
return PublicDetailsView{Line: publicDetailsNeverRead}
|
||||
}
|
||||
if !rec.Current(fingerprint) {
|
||||
return PublicDetailsView{Line: "API key changed since the last read."}
|
||||
}
|
||||
return PublicDetailsView{
|
||||
Line: fmt.Sprintf("Read %s.", rec.ReadAt.Local().Format(environmentCheckDateFormat)),
|
||||
Rows: []PublicDetailRow{
|
||||
publicDetailRow("Public business name", rec.Name, "Read after the account has a recurring price."),
|
||||
publicDetailRow("Statement descriptor", rec.Descriptor, "Read after the first payment."),
|
||||
iconRow(rec.Icon),
|
||||
},
|
||||
Shown: shownToMembers(rec, billingFactsFor(rec, true, fingerprint, deploymentName)),
|
||||
}
|
||||
}
|
||||
|
||||
// shownToMembers is the Shown to members row: the sentences members get,
|
||||
// or, when there are none, which details matched the deployment name. A
|
||||
// detail the read did not reach matches nothing and shows nothing, so it
|
||||
// goes unnamed.
|
||||
func shownToMembers(rec stripedb.PublicDetailsRecord, facts BillingFacts) string {
|
||||
if facts.Shown() {
|
||||
return strings.Join(facts.Sentences, " ")
|
||||
}
|
||||
switch name, descriptor := rec.Name.IsRead(), rec.Descriptor.IsRead(); {
|
||||
case name && descriptor:
|
||||
return "Nothing; both match the deployment name."
|
||||
case name:
|
||||
return "Nothing; the business name matches the deployment name."
|
||||
case descriptor:
|
||||
return "Nothing; the statement descriptor matches the deployment name."
|
||||
}
|
||||
return "Nothing."
|
||||
}
|
||||
|
||||
// publicDetailRow renders the name or the descriptor. notYet is the reason
|
||||
// a detail the fallback could not reach yet carries, which differs between
|
||||
// the two.
|
||||
func publicDetailRow(label string, d stripedb.PublicDetail, notYet string) PublicDetailRow {
|
||||
row := PublicDetailRow{Label: label}
|
||||
switch d.State {
|
||||
case stripedb.DetailRead:
|
||||
row.Value = d.Value
|
||||
if d.LegalName {
|
||||
row.Notes = append(row.Notes, "Same as the legal name.")
|
||||
}
|
||||
if d.Source == stripedb.SourceLatestCharge {
|
||||
row.Notes = append(row.Notes, "As printed on the latest payment.")
|
||||
}
|
||||
case stripedb.DetailUnset:
|
||||
row.Value = "Not set"
|
||||
case stripedb.DetailNotPermitted:
|
||||
row.Reason = permissionReason(d.Permission)
|
||||
default:
|
||||
row.Reason = notYet
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// iconRow renders the icon: set, not set, or not readable by this key.
|
||||
func iconRow(d stripedb.PublicDetail) PublicDetailRow {
|
||||
row := PublicDetailRow{Label: "Icon"}
|
||||
switch d.State {
|
||||
case stripedb.DetailRead:
|
||||
row.Value = "Set"
|
||||
case stripedb.DetailNotPermitted:
|
||||
row.Reason = permissionReason(d.Permission)
|
||||
default:
|
||||
row.Value = "Not set"
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// permissionReason names the restricted-key permission a detail needs.
|
||||
func permissionReason(permission string) string {
|
||||
if permission == "" {
|
||||
permission = stripedb.PermissionAccounts
|
||||
}
|
||||
return "Needs the " + permission + " permission on the API key."
|
||||
}
|
||||
|
||||
// stripeModeLabel names the mode derived from the Stripe API key at boot
|
||||
// (server.Config.StripeMode). A deployment with no key configured reads
|
||||
// "Test mode": nothing it shows is live money, and the page states
|
||||
@@ -408,6 +537,26 @@ func stripeModeLabel(mode string) string {
|
||||
return "Test mode"
|
||||
}
|
||||
|
||||
// environmentCheckHeader and publicDetailsHeader title the two sections
|
||||
// that read Stripe's state again on demand. The header's action is the
|
||||
// control, and it swaps the section's body, whose first line says when the
|
||||
// state was last read.
|
||||
func environmentCheckHeader() SectionHeader {
|
||||
return SectionHeader{Title: "Environment check", Action: &Link{
|
||||
Label: "Check now",
|
||||
Refresh: "/partials/operator/integrations/stripe/environment-check",
|
||||
Target: "#stripe-environment-check",
|
||||
}}
|
||||
}
|
||||
|
||||
func publicDetailsHeader() SectionHeader {
|
||||
return SectionHeader{Title: "Public details", Action: &Link{
|
||||
Label: "Read again",
|
||||
Refresh: "/partials/operator/integrations/stripe/public-details",
|
||||
Target: "#stripe-public-details",
|
||||
}}
|
||||
}
|
||||
|
||||
// GetStripeIntegrationPage handles GET /operator/integrations/stripe.
|
||||
func (h *OperatorPartialsHandler) GetStripeIntegrationPage(w http.ResponseWriter, r *http.Request) {
|
||||
configured, missing := configurationReadiness(h.IntegrationConfigs, "stripe")
|
||||
@@ -419,7 +568,8 @@ func (h *OperatorPartialsHandler) GetStripeIntegrationPage(w http.ResponseWriter
|
||||
Missing: missing,
|
||||
ModeLabel: modeLabel,
|
||||
Description: stripedb.ProviderSource().ProviderManifest().Description,
|
||||
EnvironmentCheckHeader: SectionHeader{Title: "Environment check"},
|
||||
EnvironmentCheckHeader: environmentCheckHeader(),
|
||||
PublicDetailsHeader: publicDetailsHeader(),
|
||||
DeliveryQueueHeader: SectionHeader{Title: "Delivery queue"},
|
||||
InboundEventsHeader: SectionHeader{Title: "Inbound events"},
|
||||
}
|
||||
@@ -429,6 +579,7 @@ func (h *OperatorPartialsHandler) GetStripeIntegrationPage(w http.ResponseWriter
|
||||
iq = integration.New(h.Database)
|
||||
}
|
||||
bodyData.EnvironmentCheck = loadEnvironmentCheck(r.Context(), h.Database, h.StripeKeyFingerprint, h.Logger)
|
||||
bodyData.PublicDetails = loadPublicDetailsView(r.Context(), h.Database, h.StripeKeyFingerprint, h.Logger)
|
||||
bodyData.DeliveryQueue = loadDeliveryQueue(r.Context(), iq, h.Logger)
|
||||
bodyData.DeliveryQueueEntries = loadDeadLetterEntries(r.Context(), h.Database, h.Logger)
|
||||
bodyData.InboundEvents = loadInboundEvents(r.Context(), h.Database, h.StripeMode, h.Logger)
|
||||
@@ -512,3 +663,62 @@ func (h *OperatorPartialsHandler) renderStripeEnvironmentCheck(w http.ResponseWr
|
||||
h.Templates.Render(w, "operator_integration_stripe_environment.html",
|
||||
loadEnvironmentCheck(r.Context(), h.Database, h.StripeKeyFingerprint, h.Logger))
|
||||
}
|
||||
|
||||
// publicDetailsWait bounds how long the control's request waits on the
|
||||
// read, as environmentCheckWait does for the check. The read is at most
|
||||
// four calls, so the common answer is the new record.
|
||||
const publicDetailsWait = 3 * time.Second
|
||||
|
||||
// PostStripePublicDetails handles
|
||||
// POST /partials/operator/integrations/stripe/public-details: the provider
|
||||
// page's "Read again" control (merchant-disclosure D6). It starts the read
|
||||
// boot starts, under the same id, waits a few seconds on the answer, and
|
||||
// re-renders the section either way.
|
||||
func (h *OperatorPartialsHandler) PostStripePublicDetails(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.StripeConfigured {
|
||||
http.Error(w, "Stripe is not configured.", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if h.TemporalClient == nil {
|
||||
fireErrorToast(w, "Temporal is unavailable.")
|
||||
h.renderStripePublicDetails(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
run, err := stripewf.StartPublicDetailsRead(r.Context(), h.TemporalClient, queues.Main, stripewf.PublicDetailsTriggerOperator)
|
||||
if err != nil {
|
||||
if temporal.IsWorkflowExecutionAlreadyStartedError(err) {
|
||||
fireSuccessToast(w, "Read running.")
|
||||
h.renderStripePublicDetails(w, r)
|
||||
return
|
||||
}
|
||||
h.Logger.Error("failed to start the stripe public details read", slog.Any("error", err))
|
||||
fireErrorToast(w, "Could not read the public details.")
|
||||
h.renderStripePublicDetails(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
waitCtx, cancel := context.WithTimeout(r.Context(), publicDetailsWait)
|
||||
defer cancel()
|
||||
var rec stripedb.PublicDetailsRecord
|
||||
// As in PostStripeEnvironmentCheck, only the deadline means the read
|
||||
// is still going; any other error is the run failing, logged and not
|
||||
// shown.
|
||||
switch err := run.Get(waitCtx, &rec); {
|
||||
case err == nil:
|
||||
fireSuccessToast(w, "Public details read.")
|
||||
case errors.Is(waitCtx.Err(), context.DeadlineExceeded):
|
||||
fireSuccessToast(w, "Read started.")
|
||||
default:
|
||||
h.Logger.Error("the stripe public details read failed", slog.Any("error", err))
|
||||
fireErrorToast(w, "Could not read the public details.")
|
||||
}
|
||||
h.renderStripePublicDetails(w, r)
|
||||
}
|
||||
|
||||
// renderStripePublicDetails re-renders the Public details section alone,
|
||||
// the whole of what the control changes.
|
||||
func (h *OperatorPartialsHandler) renderStripePublicDetails(w http.ResponseWriter, r *http.Request) {
|
||||
h.Templates.Render(w, "operator_integration_stripe_public_details.html",
|
||||
loadPublicDetailsView(r.Context(), h.Database, h.StripeKeyFingerprint, h.Logger))
|
||||
}
|
||||
|
||||
@@ -332,8 +332,8 @@ func TestStripeEnvironmentCheckLines(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestStripeEnvironmentCheckSection renders the section and asserts it
|
||||
// carries its resting line and its one outline control, and that an
|
||||
// unconfigured deployment renders neither: there is no key to read
|
||||
// carries its resting line and its one outline control in the header, and
|
||||
// that an unconfigured deployment renders neither: there is no key to read
|
||||
// anything back under.
|
||||
func TestStripeEnvironmentCheckSection(t *testing.T) {
|
||||
tmpl := parseOperatorPartials(t)
|
||||
@@ -351,7 +351,7 @@ func TestStripeEnvironmentCheckSection(t *testing.T) {
|
||||
|
||||
out := render(StripeIntegrationData{
|
||||
Configured: true,
|
||||
EnvironmentCheckHeader: SectionHeader{Title: "Environment check"},
|
||||
EnvironmentCheckHeader: environmentCheckHeader(),
|
||||
EnvironmentCheck: EnvironmentCheck{Line: "Last checked Sep 18, 2026 3:04 PM. 34 verified, 2 stale."},
|
||||
})
|
||||
for _, want := range []string{
|
||||
@@ -366,11 +366,14 @@ func TestStripeEnvironmentCheckSection(t *testing.T) {
|
||||
t.Errorf("environment check section missing %q, got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Index(out, "Check now") > strings.Index(out, `id="stripe-environment-check"`) {
|
||||
t.Error("Check now is not in the section header")
|
||||
}
|
||||
|
||||
out = render(StripeIntegrationData{
|
||||
Configured: false,
|
||||
Missing: []string{"stripe-api-key"},
|
||||
EnvironmentCheckHeader: SectionHeader{Title: "Environment check"},
|
||||
EnvironmentCheckHeader: environmentCheckHeader(),
|
||||
EnvironmentCheck: EnvironmentCheck{Line: "Not checked yet."},
|
||||
})
|
||||
if strings.Contains(out, "Check now") {
|
||||
|
||||
@@ -346,6 +346,7 @@ func (h *OperatorPartialsHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
// (stripe-environment-stamp D3): starts the read-back of the mapped
|
||||
// Stripe ids under the key in force and re-renders its section.
|
||||
mux.HandleFunc("POST /partials/operator/integrations/stripe/environment-check", h.requireOperatorRole(h.PostStripeEnvironmentCheck))
|
||||
mux.HandleFunc("POST /partials/operator/integrations/stripe/public-details", h.requireOperatorRole(h.PostStripePublicDetails))
|
||||
mux.HandleFunc("GET /partials/operator/products/{productID}/readiness", h.requireOperatorRole(h.GetProductReadiness))
|
||||
// Duplicate-name warning (design D4): live hx-get from the create/edit
|
||||
// forms' name fields.
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"go.temporal.io/api/serviceerror"
|
||||
"go.temporal.io/sdk/client"
|
||||
"go.temporal.io/sdk/mocks"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
|
||||
stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
|
||||
)
|
||||
|
||||
// The Stripe provider page's Public details section (merchant-disclosure
|
||||
// D6): the resting line, one row per detail from a current record, what
|
||||
// members are shown because of them, and the header's control.
|
||||
|
||||
var publicDetailsRead = time.Date(2026, 9, 25, 15, 4, 0, 0, time.Local)
|
||||
|
||||
func fromAccountRecord() stripedb.PublicDetailsRecord {
|
||||
return stripedb.PublicDetailsRecord{
|
||||
KeyFingerprint: "fp",
|
||||
ReadAt: &publicDetailsRead,
|
||||
AccountRead: true,
|
||||
Name: stripedb.PublicDetail{State: stripedb.DetailRead, Value: "Acme Holdings LLC", Source: stripedb.SourceAccount, LegalName: true},
|
||||
Descriptor: stripedb.PublicDetail{State: stripedb.DetailRead, Value: "ACMEHOLDINGS", Source: stripedb.SourceAccount, LegalName: true},
|
||||
Icon: stripedb.PublicDetail{State: stripedb.DetailUnset, Source: stripedb.SourceAccount},
|
||||
}
|
||||
}
|
||||
|
||||
func fallbackRecord(descriptor stripedb.PublicDetail) stripedb.PublicDetailsRecord {
|
||||
return stripedb.PublicDetailsRecord{
|
||||
KeyFingerprint: "fp",
|
||||
ReadAt: &publicDetailsRead,
|
||||
Name: stripedb.PublicDetail{State: stripedb.DetailRead, Value: "Acme Holdings LLC", Source: stripedb.SourceInvoicePreview},
|
||||
Descriptor: descriptor,
|
||||
Icon: stripedb.PublicDetail{State: stripedb.DetailNotPermitted, Permission: stripedb.PermissionAccounts},
|
||||
}
|
||||
}
|
||||
|
||||
func rowByLabel(t *testing.T, v PublicDetailsView, label string) PublicDetailRow {
|
||||
t.Helper()
|
||||
for _, r := range v.Rows {
|
||||
if r.Label == label {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("no %q row in %+v", label, v.Rows)
|
||||
return PublicDetailRow{}
|
||||
}
|
||||
|
||||
func TestPublicDetailsViewFromTheAccount(t *testing.T) {
|
||||
v := publicDetailsViewFor(fromAccountRecord(), true, "fp", "Riverside Makers")
|
||||
name := rowByLabel(t, v, "Public business name")
|
||||
if name.Value != "Acme Holdings LLC" || strings.Join(name.Notes, " ") != "Same as the legal name." {
|
||||
t.Errorf("name row = %+v, want the name noted as the legal name", name)
|
||||
}
|
||||
desc := rowByLabel(t, v, "Statement descriptor")
|
||||
if desc.Value != "ACMEHOLDINGS" || strings.Join(desc.Notes, " ") != "Same as the legal name." {
|
||||
t.Errorf("descriptor row = %+v, want the value noted as the legal name and no source note", desc)
|
||||
}
|
||||
icon := rowByLabel(t, v, "Icon")
|
||||
if icon.Value != "Not set" || len(icon.Notes) != 0 || icon.Reason != "" {
|
||||
t.Errorf("icon row = %+v, want Not set as plain text", icon)
|
||||
}
|
||||
if v.Shown != "Billed by Acme Holdings LLC. Card statement: ACMEHOLDINGS." {
|
||||
t.Errorf("Shown to members = %q, want both sentences members see", v.Shown)
|
||||
}
|
||||
if v.Line != "Read Sep 25, 2026 3:04 PM." {
|
||||
t.Errorf("line = %q", v.Line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicDetailsViewFallbackBeforeTheFirstPayment(t *testing.T) {
|
||||
v := publicDetailsViewFor(fallbackRecord(stripedb.PublicDetail{State: stripedb.DetailNotYet}), true, "fp", "Riverside Makers")
|
||||
if r := rowByLabel(t, v, "Public business name"); r.Value != "Acme Holdings LLC" || len(r.Notes) != 0 {
|
||||
t.Errorf("name row = %+v, want the preview's name and no note (no legal name to compare)", r)
|
||||
}
|
||||
if r := rowByLabel(t, v, "Statement descriptor"); r.Reason != "Read after the first payment." {
|
||||
t.Errorf("descriptor row = %+v", r)
|
||||
}
|
||||
if r := rowByLabel(t, v, "Icon"); r.Reason != "Needs the Accounts Read permission on the API key." {
|
||||
t.Errorf("icon row = %+v", r)
|
||||
}
|
||||
if v.Shown != "Billed by Acme Holdings LLC." {
|
||||
t.Errorf("Shown to members = %q", v.Shown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicDetailsViewFallbackWithACharge(t *testing.T) {
|
||||
v := publicDetailsViewFor(fallbackRecord(stripedb.PublicDetail{
|
||||
State: stripedb.DetailRead, Value: "ACMEHOLDINGS", Source: stripedb.SourceLatestCharge,
|
||||
}), true, "fp", "Riverside Makers")
|
||||
if r := rowByLabel(t, v, "Statement descriptor"); r.Value != "ACMEHOLDINGS" || strings.Join(r.Notes, " ") != "As printed on the latest payment." {
|
||||
t.Errorf("descriptor row = %+v, want the charge's text and its source", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicDetailsViewRefusedReads(t *testing.T) {
|
||||
rec := fallbackRecord(stripedb.PublicDetail{State: stripedb.DetailNotPermitted, Permission: stripedb.PermissionCharges})
|
||||
rec.Name = stripedb.PublicDetail{State: stripedb.DetailNotPermitted, Permission: stripedb.PermissionInvoices}
|
||||
v := publicDetailsViewFor(rec, true, "fp", "Riverside Makers")
|
||||
if r := rowByLabel(t, v, "Public business name"); r.Reason != "Needs the Invoices permission on the API key." {
|
||||
t.Errorf("name row = %+v", r)
|
||||
}
|
||||
if r := rowByLabel(t, v, "Statement descriptor"); r.Reason != "Needs the Charges permission on the API key." {
|
||||
t.Errorf("descriptor row = %+v", r)
|
||||
}
|
||||
if v.Shown != "Nothing." {
|
||||
t.Errorf("nothing was read, yet members are said to see %q", v.Shown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicDetailsViewWithRecognisedNames(t *testing.T) {
|
||||
rec := fromAccountRecord()
|
||||
rec.Name = stripedb.PublicDetail{State: stripedb.DetailRead, Value: "Riverside Makers", Source: stripedb.SourceAccount}
|
||||
rec.Descriptor = stripedb.PublicDetail{State: stripedb.DetailRead, Value: "RIVERSIDE MAKERS", Source: stripedb.SourceAccount}
|
||||
v := publicDetailsViewFor(rec, true, "fp", "Riverside Makers Member Console")
|
||||
if v.Shown != "Nothing; both match the deployment name." {
|
||||
t.Errorf("recognised names: Shown to members = %q", v.Shown)
|
||||
}
|
||||
|
||||
// One detail read and recognised, the other not reached: the row names
|
||||
// only the one that matched.
|
||||
rec.Descriptor = stripedb.PublicDetail{State: stripedb.DetailNotYet}
|
||||
if v := publicDetailsViewFor(rec, true, "fp", "Riverside Makers"); v.Shown != "Nothing; the business name matches the deployment name." {
|
||||
t.Errorf("name only: Shown to members = %q", v.Shown)
|
||||
}
|
||||
rec.Descriptor = stripedb.PublicDetail{State: stripedb.DetailRead, Value: "RIVERSIDE MAKERS", Source: stripedb.SourceLatestCharge}
|
||||
rec.Name = stripedb.PublicDetail{State: stripedb.DetailNotPermitted, Permission: stripedb.PermissionInvoices}
|
||||
if v := publicDetailsViewFor(rec, true, "fp", "Riverside Makers"); v.Shown != "Nothing; the statement descriptor matches the deployment name." {
|
||||
t.Errorf("descriptor only: Shown to members = %q", v.Shown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicDetailsViewNoCurrentRecord(t *testing.T) {
|
||||
if v := publicDetailsViewFor(stripedb.PublicDetailsRecord{}, false, "fp", "Riverside Makers"); v.Line != "Not read yet." || len(v.Rows) != 0 {
|
||||
t.Errorf("no record: %+v", v)
|
||||
}
|
||||
if v := publicDetailsViewFor(fromAccountRecord(), true, "another-key", "Riverside Makers"); v.Line != "API key changed since the last read." || len(v.Rows) != 0 || v.Shown != "" {
|
||||
t.Errorf("a record under another key showed its values: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicDetailsSectionRender(t *testing.T) {
|
||||
tmpl := parseOperatorPartials(t)
|
||||
render := func(data StripeIntegrationData) string {
|
||||
t.Helper()
|
||||
data.ModeLabel = "Test mode"
|
||||
data.DeliveryQueueHeader = SectionHeader{Title: "Delivery queue"}
|
||||
data.InboundEventsHeader = SectionHeader{Title: "Inbound events"}
|
||||
data.EnvironmentCheckHeader = environmentCheckHeader()
|
||||
data.PublicDetailsHeader = publicDetailsHeader()
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "operator_integration_stripe.html", data); err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
out := render(StripeIntegrationData{
|
||||
Configured: true,
|
||||
PublicDetails: publicDetailsViewFor(fromAccountRecord(), true, "fp", "Riverside Makers"),
|
||||
})
|
||||
for _, want := range []string{
|
||||
"Public details", `id="stripe-public-details"`,
|
||||
"Public business name", "Acme Holdings LLC", "Same as the legal name.",
|
||||
"Statement descriptor", "ACMEHOLDINGS", "Icon", "Not set",
|
||||
"Shown to members", "Billed by Acme Holdings LLC. Card statement: ACMEHOLDINGS.",
|
||||
"Read Sep 25, 2026 3:04 PM.", "Read again",
|
||||
`hx-post="/partials/operator/integrations/stripe/public-details"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("section missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, `class="badge`) && strings.Index(out, `class="badge`) > strings.Index(out, "Public details") &&
|
||||
strings.Index(out, `class="badge`) < strings.Index(out, "Environment check") {
|
||||
t.Error("the Public details section renders a badge")
|
||||
}
|
||||
// The control sits in the header, beside the title, and the resting
|
||||
// line is the body's first line.
|
||||
header := out[strings.Index(out, "Public details"):strings.Index(out, `id="stripe-public-details"`)]
|
||||
if !strings.Contains(header, "Read again") {
|
||||
t.Error("Read again is not in the section header")
|
||||
}
|
||||
if strings.Index(out, "Read Sep 25, 2026 3:04 PM.") > strings.Index(out, "Public business name") {
|
||||
t.Error("the resting line renders below the details")
|
||||
}
|
||||
if strings.Index(out, "Public details") > strings.Index(out, "Environment check") {
|
||||
t.Error("Public details renders below Environment check")
|
||||
}
|
||||
|
||||
out = render(StripeIntegrationData{Configured: false, Missing: []string{"stripe-api-key"},
|
||||
PublicDetails: PublicDetailsView{Line: "Not read yet."}})
|
||||
if strings.Contains(out, "Public details") || strings.Contains(out, "Read again") {
|
||||
t.Error("an unconfigured deployment shows the Public details section")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostStripePublicDetails(t *testing.T) {
|
||||
newHandler := func(t *testing.T, c client.Client) *OperatorPartialsHandler {
|
||||
t.Helper()
|
||||
h, err := NewOperatorPartialsHandler(OperatorPartialsConfig{
|
||||
StripeMode: "test",
|
||||
StripeConfigured: true,
|
||||
TemporalClient: c,
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
AuthConfig: &auth.Config{SessionManager: scs.New()},
|
||||
IntegrationConfigs: stripeConfigs,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewOperatorPartialsHandler: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
toast := func(t *testing.T, w *httptest.ResponseRecorder, key string) string {
|
||||
t.Helper()
|
||||
var fired map[string]string
|
||||
if err := json.Unmarshal([]byte(w.Header().Get("HX-Trigger")), &fired); err != nil {
|
||||
t.Fatalf("HX-Trigger %q: %v", w.Header().Get("HX-Trigger"), err)
|
||||
}
|
||||
return fired[key]
|
||||
}
|
||||
post := func(t *testing.T, h *OperatorPartialsHandler, budget time.Duration) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), budget)
|
||||
t.Cleanup(cancel)
|
||||
r := httptest.NewRequestWithContext(ctx, "POST", "/partials/operator/integrations/stripe/public-details", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.PostStripePublicDetails(w, r)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `id="stripe-public-details"`) {
|
||||
t.Errorf("the response does not re-render the section: %s", w.Body.String())
|
||||
}
|
||||
return w
|
||||
}
|
||||
withRun := func(run *mocks.WorkflowRun) client.Client {
|
||||
c := &mocks.Client{}
|
||||
c.On("ExecuteWorkflow", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(run, nil)
|
||||
return c
|
||||
}
|
||||
|
||||
t.Run("the read finishes inside the wait", func(t *testing.T) {
|
||||
run := &mocks.WorkflowRun{}
|
||||
run.On("Get", mock.Anything, mock.Anything).Return(nil)
|
||||
if got := toast(t, post(t, newHandler(t, withRun(run)), time.Minute), "showSuccessToast"); got != "Public details read." {
|
||||
t.Errorf("toast = %q", got)
|
||||
}
|
||||
})
|
||||
t.Run("the wait runs out", func(t *testing.T) {
|
||||
run := &mocks.WorkflowRun{}
|
||||
run.On("Get", mock.Anything, mock.Anything).Return(func(ctx context.Context, _ interface{}) error {
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
})
|
||||
if got := toast(t, post(t, newHandler(t, withRun(run)), 50*time.Millisecond), "showSuccessToast"); got != "Read started." {
|
||||
t.Errorf("toast = %q", got)
|
||||
}
|
||||
})
|
||||
t.Run("the read fails", func(t *testing.T) {
|
||||
run := &mocks.WorkflowRun{}
|
||||
run.On("Get", mock.Anything, mock.Anything).Return(errors.New("stripe get account: 500"))
|
||||
w := post(t, newHandler(t, withRun(run)), time.Minute)
|
||||
if got := toast(t, w, "showErrorToast"); got != "Could not read the public details." {
|
||||
t.Errorf("error toast = %q", got)
|
||||
}
|
||||
})
|
||||
t.Run("a read already holds the id", func(t *testing.T) {
|
||||
c := &mocks.Client{}
|
||||
c.On("ExecuteWorkflow", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||
Return(nil, serviceerror.NewWorkflowExecutionAlreadyStarted("already started", "start-request", "run-id"))
|
||||
if got := toast(t, post(t, newHandler(t, c), time.Minute), "showSuccessToast"); got != "Read running." {
|
||||
t.Errorf("toast = %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -489,6 +489,8 @@ func Start(ctx context.Context, cfg Config) error {
|
||||
Logger: cfg.Logger,
|
||||
Database: cfg.Database,
|
||||
StripeMode: cfg.StripeMode,
|
||||
|
||||
StripeKeyFingerprint: cfg.StripeKeyFingerprint,
|
||||
})
|
||||
if err != nil {
|
||||
cfg.Logger.Error("failed to set up Member Products handler", slog.Any("error", err))
|
||||
@@ -823,6 +825,10 @@ func Start(ctx context.Context, cfg Config) error {
|
||||
|
||||
data := IndexPageData{Shell: memberShell(r, "dashboard"), Name: name, Username: username, Email: email, KeycloakAccountURL: keycloakAccountURL, IsOperator: isOperator, HasMultipleWorkspaces: hasMultipleWorkspaces, CheckoutStatus: checkoutStatus, DashboardCards: cards, Workspaces: workspaces, PendingDomainClaims: pendingDomainClaims, HasEntitlement: hasEntitlement}
|
||||
|
||||
if checkoutStatus == "success" {
|
||||
data.BillingFacts = loadBillingFacts(ctx, cfg.Database, cfg.StripeKeyFingerprint, cfg.Logger)
|
||||
}
|
||||
|
||||
safeTmpl.Render(w, "index.html", data)
|
||||
})
|
||||
|
||||
|
||||
@@ -464,3 +464,46 @@ func TestFormPartLinkCommitLeavesTheConsole(t *testing.T) {
|
||||
t.Errorf("the way out stays beside a link commit:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// FormView.CommitDescribedBy names an element outside the form that states
|
||||
// a fact about what committing does. The enabled submit and a link commit
|
||||
// reference it; a disabled commit keeps only its own reason.
|
||||
func TestFormPartCommitDescribedBy(t *testing.T) {
|
||||
spec := forms.FormSpec{
|
||||
Name: "test.commit-described", Kind: forms.KindPreview, Family: forms.Stacked,
|
||||
Method: "POST", Path: "/partials/test", Commit: "Confirm",
|
||||
WayOut: forms.ScopedLinkOut("Keep current plan", "/partials/test", "#test"),
|
||||
Fields: []forms.Field{{Name: "ladder_id", Label: "Ladder", Control: forms.Hidden}},
|
||||
}
|
||||
render := func(set func(*forms.FormView)) string {
|
||||
t.Helper()
|
||||
view := forms.Render(spec, forms.Binding{Mode: forms.ModeRecord, Message: template.HTML("<p>Something is charged.</p>")})
|
||||
set(&view)
|
||||
return renderFormPart(t, view)
|
||||
}
|
||||
|
||||
out := render(func(v *forms.FormView) { v.CommitDescribedBy = "billing-line" })
|
||||
if !strings.Contains(out, `<button type="submit" class="btn btn-primary" aria-describedby="billing-line">`) {
|
||||
t.Errorf("the enabled commit must reference the element:\n%s", out)
|
||||
}
|
||||
|
||||
out = render(func(v *forms.FormView) {
|
||||
v.CommitDescribedBy = "billing-line"
|
||||
v.CommitLink = &forms.CommitLinkView{Label: "Approve payment", URL: "https://invoice.stripe.com/i/test"}
|
||||
})
|
||||
if !strings.Contains(out, `rel="noopener" aria-describedby="billing-line">Approve payment</a>`) {
|
||||
t.Errorf("a link commit must reference the element:\n%s", out)
|
||||
}
|
||||
|
||||
out = render(func(v *forms.FormView) {
|
||||
v.CommitDescribedBy = "billing-line"
|
||||
v.CommitState = &forms.DisabledControlView{ID: "why-not", Label: "Confirm", Reason: "Choose first."}
|
||||
})
|
||||
if strings.Contains(out, "billing-line") {
|
||||
t.Errorf("a disabled commit must not reference the element:\n%s", out)
|
||||
}
|
||||
|
||||
if out := render(func(*forms.FormView) {}); strings.Contains(out, "aria-describedby") {
|
||||
t.Errorf("an unset CommitDescribedBy must write nothing:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,18 @@ type MockBackend struct {
|
||||
// ObjectErrs answers one Stripe id with one error, ahead of the maps:
|
||||
// a rate limit, a network fault, anything a missing object cannot say.
|
||||
ObjectErrs map[string]error
|
||||
// Account answers GET /v1/account, the public-details read's first
|
||||
// question. Nil answers 403, the refusal a restricted key without
|
||||
// Accounts Read receives.
|
||||
Account *stripe.Account
|
||||
// PriceList answers GET /v1/prices, and Charges GET /v1/charges, the
|
||||
// public-details fallback's two lists.
|
||||
PriceList []*stripe.Price
|
||||
Charges []*stripe.Charge
|
||||
// PathErrs answers one API path with one error, ahead of everything
|
||||
// else, so a test can refuse the invoice preview or the charge list
|
||||
// the way Stripe refuses a key without the permission.
|
||||
PathErrs map[string]error
|
||||
// UpdateSub, when set, answers a subscription update (POST
|
||||
// /v1/subscriptions/{id}) in place of Sub, so a test can return what
|
||||
// Stripe answers an update with (a pending_update, say) while a later
|
||||
@@ -81,6 +93,16 @@ func (m *MockBackend) CallsTo(method, path string) []Call {
|
||||
return out
|
||||
}
|
||||
|
||||
// PermissionDenied is Stripe's answer to a restricted key that lacks the
|
||||
// permission an endpoint needs: HTTP 403 with an invalid-request error.
|
||||
func PermissionDenied(permission string) error {
|
||||
return &stripe.Error{
|
||||
Type: stripe.ErrorTypeInvalidRequest,
|
||||
HTTPStatusCode: 403,
|
||||
Msg: "Permission denied. The provided key does not have the required permissions for this endpoint ('" + permission + "').",
|
||||
}
|
||||
}
|
||||
|
||||
// resourceMissing is Stripe's answer for an id that does not exist under
|
||||
// the key it was asked with, built here so a test need not construct one.
|
||||
func resourceMissing(id string) error {
|
||||
@@ -107,6 +129,9 @@ func (m *MockBackend) Call(method, path, key string, params stripe.ParamsContain
|
||||
if m.Err != nil {
|
||||
return m.Err
|
||||
}
|
||||
if err, ok := m.PathErrs[path]; ok {
|
||||
return err
|
||||
}
|
||||
var payload any
|
||||
if m.Handle != nil {
|
||||
if p, err, handled := m.Handle(c); handled {
|
||||
@@ -117,6 +142,15 @@ func (m *MockBackend) Call(method, path, key string, params stripe.ParamsContain
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case path == "/v1/account":
|
||||
if m.Account == nil {
|
||||
return PermissionDenied("connected_account_read")
|
||||
}
|
||||
payload = m.Account
|
||||
case path == "/v1/prices":
|
||||
payload = &stripe.PriceList{Data: m.PriceList}
|
||||
case path == "/v1/charges":
|
||||
payload = &stripe.ChargeList{Data: m.Charges}
|
||||
case path == "/v1/subscriptions":
|
||||
payload = &stripe.SubscriptionList{Data: m.List}
|
||||
case strings.HasPrefix(path, "/v1/subscriptions/"):
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-25
|
||||
@@ -0,0 +1,128 @@
|
||||
## Context
|
||||
|
||||
Stripe Checkout, receipts and card statements carry two values the console does not control: the Stripe account's public business name and its statement descriptor. Stripe fills both from the account's registered legal name until an operator sets them. The console hands members to Checkout from the Products page (`member_plans.html` tier cards, `member_addons.html` table rows) through `POST /billing/checkout` and an `HX-Redirect`, and greets them on return with an alert on the dashboard (`index.html`, `?checkout=success`).
|
||||
|
||||
The console already reads Stripe back once in the background: the environment check (`internal/integrations/stripe/workflows/environment_check.go`) runs as a Temporal workflow under a fixed id, started at boot and by the provider page's `Check now`, and stores its outcome as a JSON instance setting (`stripe.environment_check`) that the provider page renders. This change follows that shape.
|
||||
|
||||
A recorded decision stands in the way of the obvious read. `docs/stripe.md` and the `ConfigSpec` comment in `internal/integrations/stripe/stripe.go` say the console never reads the account, because `GET /v1/account` on a restricted key needs the "Accounts Read" permission (`connected_account_read`), and a restricted key holding only what the console uses is the recommended deployment. Tested against a sandbox on 2026-09-25 with a restricted test key and with a full secret key:
|
||||
|
||||
- `GET /v1/account` answers 403 to the restricted key, naming `connected_account_read`, and 200 to the full key.
|
||||
- `POST /v1/invoices/create_preview` with one recurring price and no customer answers 200 to the restricted key, creates nothing, and returns `account_name`, the name on receipts and in Checkout's header.
|
||||
- `GET /v1/charges?limit=1` returns `calculated_statement_descriptor`, the text on the card statement, once a charge exists.
|
||||
- Checkout's `branding_settings.display_name`, when not overridden, equals the same name.
|
||||
- On the full read, the sandbox's `business_profile.name` was not empty: Stripe had copied `company.name` into it, and `settings.payments.statement_descriptor` held the same name in capitals. An unset name is therefore not a blank field; it is a name equal to the legal name.
|
||||
- The full read also returns `email`, `company`, `capabilities`, `charges_enabled`, `payouts_enabled` and `settings`. It did not return bank accounts. Stripe's permission reference lists `connected_account_read` as the only permission for the account object; there is no narrower one.
|
||||
|
||||
The member-facing placement was settled with the maintainer on 2026-09-25 from mocks on the demo stack and a six-ladder page: one line under each purchase section's heading, never under every card, never an interstitial.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- A member learns who bills them, and the text on their card statement, before leaving for Stripe and again on return, whenever either would not be recognised against the deployment's own name.
|
||||
- A deployment whose Stripe settings match its name shows members nothing new.
|
||||
- An operator sees on the console the names members see, which of them still equal the legal name, and what the key could not read.
|
||||
- No new permission is required. A key that can read the account is used for more; a key that cannot still gets the name.
|
||||
- No Stripe call on any page render.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Overriding Checkout's header with `branding_settings.display_name`. It changes only the header; receipts, the terms line and the card statement keep the account's name, and hiding the header's symptom hides the rest from the operator.
|
||||
- Setting the statement descriptor per product or per charge.
|
||||
- Keeping the read fresh on a schedule. Boot and the operator's control are the triggers (D1).
|
||||
- Any setup-checklist gate. The finding is something to confirm, not a blocker.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1. The read runs as a workflow beside the environment check, at every boot and on request
|
||||
|
||||
`StripePublicDetailsWorkflow` runs under the fixed id `stripe-public-details`, one activity, a one-minute attempt budget and Temporal's retry policy. Boot starts it on every start with Stripe configured, and does not wait. The provider page's `Read again` control starts the same id and waits up to three seconds for the answer, as `Check now` does. Every boot, not only on a key change, because the values live in Stripe's settings and change without the console knowing, and the whole read is at most three calls.
|
||||
|
||||
Rejected: reading on the provider page's render (a Stripe call inside a request, and member pages would still need a stored copy); a daily schedule (a second trigger to keep in step with deploys, for a value an operator changes rarely and then comes to the console to confirm); re-reading from every Checkout session's `branding_settings` (only the name, and only after someone buys).
|
||||
|
||||
### D2. What the key allows decides the path, one field at a time
|
||||
|
||||
The activity asks for the account first.
|
||||
|
||||
- **The account answers.** The record takes `business_profile.name`, `settings.payments.statement_descriptor`, and whether `settings.branding.icon` is set. It compares the name and the descriptor with the legal name: `company.name` for a company account, the individual's first and last name for an individual account when Stripe returns them. When neither is returned, no comparison is recorded.
|
||||
- **The account answers 403.** The activity lists one active recurring price (`price.List`, `active`, `type=recurring`, limit 1) and asks for an invoice preview with it and no customer; the preview's `account_name` is the name. It then lists one charge; its `calculated_statement_descriptor` is the descriptor. The icon and the legal-name comparison are not read.
|
||||
- Each fallback read stands alone. A 403 on the preview or on the charge list records that field as not permitted and names the permission; no recurring price, or no charge yet, records the field as not yet readable. Neither fails the run.
|
||||
- Any other error (network, 5xx, an invalid key) fails the activity and Temporal retries it. The previous record stays until a run succeeds.
|
||||
|
||||
Rejected: requiring Accounts Read (the recorded decision stands for operators who keep the minimal key); a fallback only (loses the descriptor before the first payment, which is the value with money at stake).
|
||||
|
||||
### D3. The record is an instance setting keyed to the API key
|
||||
|
||||
The record lives under `stripe.public_details` in `core.instance_settings`, the JSON kind the environment check introduced; no migration. Its shape belongs to the Stripe store, beside `EnvironmentCheckRecord`:
|
||||
|
||||
```
|
||||
key_fingerprint the digest of the key the read ran under
|
||||
read_at when the run finished
|
||||
account_read whether the account itself answered
|
||||
name, descriptor, icon:
|
||||
state read | unset | not_permitted | not_yet
|
||||
value the text, when read
|
||||
source account | invoice_preview | latest_charge
|
||||
legal_name true when the value equals the legal name (account read only)
|
||||
permission the permission a not_permitted field needs
|
||||
```
|
||||
|
||||
The legal name itself is not stored, only the comparison. Consumers use the record only when its fingerprint equals the key in force; a record from another key reads as no record, so a member never sees the previous environment's name.
|
||||
|
||||
### D4. Recognition is one helper with a strict rule
|
||||
|
||||
`internal/server/billing_facts.go` holds the rule both surfaces use, from the three values the console knows: the deployment name `C` (`config.DeploymentName()`), the name `M` and the descriptor `D` from a current record.
|
||||
|
||||
1. A comparison key is the value under NFKC normalisation and Unicode case folding, keeping only letters, marks and digits. Displayed values are the originals, escaped.
|
||||
2. The recognised keys are the key of `C` and, when it leaves a non-empty name, the key of `C` without trailing whole words "Member Console". The default name "Member Console" therefore recognises nothing but itself.
|
||||
3. `Billed by {M}.` is shown when `M` was read and its key matches no recognised key.
|
||||
4. `Card statement: {D}.` is shown when `D` was read and its key matches no recognised key, independently of the first sentence. A descriptor at Stripe's 22-character limit whose key is the start of a recognised key also counts as recognised: a deployment whose name is longer cannot set it in full, and Stripe, not the operator, cut it.
|
||||
5. No other partial matching, no abbreviation guessing, no legal-suffix stripping. A near miss shows the line; a line shown when it was not needed costs one quiet sentence, and a line hidden when it was needed costs the surprise this change exists to prevent.
|
||||
|
||||
The helper returns the sentences, and a template partial `billingFacts` renders them as one muted small paragraph with a stable id. `golang.org/x/text` moves from indirect to direct.
|
||||
|
||||
### D5. The line sits once under each purchase section's heading
|
||||
|
||||
- **Plans.** The first element inside `#member-plans`, directly under the `Plans` heading, when at least one tier's move is enabled and costs money: a checkout, or a switch, whose target is always a paid tier and which charges the card on file now or at renewal. It re-renders with the partial on every switch or cancel swap, so it never disagrees with the buttons below it. Id `plans-billing-facts`.
|
||||
- **Add-ons.** After the section header, and after its lead sentence when plans exist. Every listed add-on is purchasable by construction, so the section's presence is the condition. Id `addons-billing-facts`.
|
||||
- **Buttons.** Every enabled control that costs money (a checkout's `Subscribe`, `Upgrade` or `Add`, a switch's `Upgrade` or `Downgrade`, and the switch preview's commit, `Confirm switch` or the `Approve payment` link) carries `aria-describedby` naming its section's line when the line is shown. Cancel, keep and disabled controls carry nothing. The preview's commit is the form part's, so the form library gains `FormView.CommitDescribedBy`, the id of an element outside the form that describes the commit; the part writes it on an enabled submit or a link commit, never on a disabled one.
|
||||
- **Return.** Inside the existing success alert, a second line with the same sentences. A cancelled return adds nothing.
|
||||
- Style: `text-muted small`, the add-ons lead sentence's style. No bold, no monospace.
|
||||
|
||||
Rejected, each after being mocked: one line at the foot of the page (on a six-ladder page it sits far below every button, and a member can buy without passing it); one line per ladder (seven identical lines on the same page); one line at the top of the page only (the add-on buttons end up screens away from it); an interstitial (an extra step in every purchase to carry a fact the page can hold, and a full-page warning reads like the phishing it is meant to prevent).
|
||||
|
||||
### D6. The provider page gains a Public details section
|
||||
|
||||
Between `Details` and `Environment check`, titled `Public details`, Stripe's own name for the settings involved. Rendered only when Stripe is configured.
|
||||
|
||||
- A resting line directly under the heading: `Not read yet.`, `Read Sep 25, 2026 3:04 PM.`, or `API key changed since the last read.`, in the page's date format.
|
||||
- A definition list in a card, like the page's Details list: `Public business name`, `Statement descriptor`, `Icon`. Every value is plain text, with no badge. A value equal to the legal name is followed by a muted `Same as the legal name.`; the descriptor read from a charge by a muted `As printed on the latest payment.`; an unset value, the icon included, reads `Not set`, and a set icon `Set`. A badge was tried and dropped: a status badge on a value reads as the value's status, and a legal name is sometimes intended.
|
||||
- A field not read says why in the value cell: `Needs the Accounts Read permission on the API key.`, `Needs the Invoices permission on the API key.`, `Needs the Charges permission on the API key.`, `Read after the account has a recurring price.`, `Read after the first payment.`
|
||||
- A last row `Shown to members`, always present with the list: the exact sentences members see, or, when there are none, why: `Nothing; both match the deployment name.`, `Nothing; the business name matches the deployment name.` or `Nothing; the statement descriptor matches the deployment name.` when only one was read, and `Nothing.` when neither was.
|
||||
- The control `Read again` in the section header's action slot, a declared action trigger, posting to `/partials/operator/integrations/stripe/public-details` and re-rendering the section's body alone. The environment check's `Check now` moves to its own header the same way. This is the header's refresh action (`Link.Refresh`): an outline button with no opener glyph, since it opens nothing, whose section body starts with its as-of line. Toasts: `Public details read.`, `Read started.` (the wait ran out), `Could not read the public details.`, `Read running.` (a run already holds the id).
|
||||
|
||||
### D7. The documentation states the step and the trade-off
|
||||
|
||||
`docs/stripe.md` gains `## What members see`: the three settings, where they live in the Stripe dashboard (Settings, Business details; Settings, Branding), that a sandbox is a separate account with its own copy, what the console shows members and when, and the Accounts Read permission: optional, read-only, on an ordinary account it reads that account's profile, email, company details and status, and on a Connect platform it also reads every connected account. The paragraph saying the console never reads the account is rewritten to say it reads it when permitted and never stores its id. `docs/production-deployment.md` points at the section from its setup order. The `ConfigSpec` comment in `stripe.go` is rewritten to match.
|
||||
|
||||
### D8. Every scenario is captured by a scripted sheet
|
||||
|
||||
A capture test, `test/e2e/merchant-disclosure/`, skipped unless `MERCHANT_SHEET=1`, seeds the test stack for each scenario (catalog shape, member position, record state), checks the lines each scenario should and should not show, captures the Products page, the dashboard return and the provider page at 1280 and 390 pixels, and writes `test/screens/merchant-disclosure/sheet.html`. The folder sits beside `test/screens/out/`, not in it, because `make screens` empties `out/` on every run. The test resets the application database to the demo snapshot before it seeds and again when it ends, as `make screens` does; its one real Stripe call is `Read again`, whose record supplies the key fingerprint every seeded record reuses. The real read is verified separately against the sandbox with a restricted key and with a full key, and those provider pages, with the billing-off pages, are captured by hand into the same folder, where the sheet lists them.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [A 403 means something other than a missing permission] → The activity treats only HTTP 403 as not permitted; 401 (a revoked key) and every other failure fail the run, which Temporal retries and the log names.
|
||||
- [The fallback descriptor lags a change the operator made after the last charge] → The page says the value came from the latest payment; the member line is rarely wrong for long, since the next charge corrects it.
|
||||
- [The preview's price is not one the console sells] → The account name does not depend on the price; any active recurring price in the account serves.
|
||||
- [A strict rule shows the line on a harmless variant, "Riverside Makers" beside "RIVERSIDE MAKERS CO"] → Accepted: a false line is one quiet sentence; the operator page names exactly what members see, and matching the names in Stripe removes it.
|
||||
- [A record read under the old key after a key change] → The fingerprint check hides it until the boot-started read finishes.
|
||||
- [Accounts Read on a Connect platform exposes connected accounts] → The docs say so; the permission stays optional.
|
||||
- [The read never runs because Temporal is down at boot] → The provider page says `Not read yet.`, members see nothing new, and `Read again` recovers.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
None beyond deploying. The first boot after deploy starts the read; until it finishes, pages behave as today. Rolling back removes the section and the lines; the stray instance setting row is harmless.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None blocking. Whether the docs should recommend adding Accounts Read to the minimal key, or only describe it, is a wording choice the maintainer confirms at review; the design describes it as optional and recommended.
|
||||
@@ -0,0 +1,34 @@
|
||||
## Why
|
||||
|
||||
Buying a plan or a product hands the member to Stripe Checkout, a page the console does not render. Its header, the receipts and the card statement carry the Stripe account's public business name and statement descriptor, and when an operator never sets them Stripe copies the account's registered legal name into both. Where the legal name differs from the deployment's own, the first time a member reads it is above the card fields, and again on a card statement they may dispute. Two independent walks of the purchase path reported that surprise (forge issue #13). The console is software other operators deploy with their own Stripe accounts, so each install has to notice it on its own: a fix made once in one account's settings reaches no other deployment.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **The console reads the public details members will see, with whatever permission its key already has.** A Temporal workflow reads them at every boot and on an operator's request, and stores the result in an instance setting. Where the key can read the Stripe account, it records the public business name, the statement descriptor, whether an icon is set, and whether the name or the descriptor equals the account's legal name. Where it cannot, it records the name an invoice preview carries, and the statement descriptor of the latest charge once one exists. The console never requires a new permission and never stores the account id.
|
||||
- **The Stripe provider page shows those details.** A `Public details` section lists the business name, the statement descriptor and the icon; notes the name or the descriptor that equals the legal name; says whether an icon is set; states what the key could not read and which permission would allow it; names what members are shown, or that they are shown nothing; and offers a control in its header that reads again.
|
||||
- **The Products page names who bills the member, only when it would surprise them.** One muted line under the Plans heading and one under the add-ons heading, each only when its section has a button that costs money and the billing name or the statement text would not be recognised against the deployment's own name: `Billed by Acme Holdings LLC.` and `Card statement: ACMEHOLDINGS.`, each sentence on its own condition. Every button that costs money is described by its section's line for screen readers. A deployment whose Stripe settings match its name shows nothing new.
|
||||
- **The return from Stripe repeats the same facts.** The dashboard's thank-you notice after a paid checkout carries the same sentences under the same condition.
|
||||
- **The documentation carries the setup step and the permission trade-off.** `docs/stripe.md` tells operators to set the public business name, the statement descriptor and the icon on the live account and on every sandbox, and describes the optional Accounts Read permission, what it exposes, and what the console reads without it.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- None. The read extends the Stripe integration; the disclosure extends the Products page and the checkout return.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `stripe-integration-infrastructure`: a public-details read, its workflow, triggers and record, and the two read paths chosen by what the key allows.
|
||||
- `integration-settings`: the Stripe provider page gains the `Public details` section and its control.
|
||||
- `member-product-discovery`: the Plans and add-ons sections carry the billing line under the recognition rule, and their buttons that cost money, the switch preview's commit included, reference it.
|
||||
- `form-library`: a render may name an element outside the form that describes the commit.
|
||||
- `member-plan-upgrade`: the successful-return acknowledgement carries the billing facts under the same rule.
|
||||
- `deployment-docs`: the Stripe guide carries the public-details setup step and the Accounts Read trade-off.
|
||||
|
||||
## Impact
|
||||
|
||||
- Code: a workflow and activity beside the environment check in `internal/integrations/stripe/workflows/`, its record type in the Stripe store, one new `core.instance_settings` key in the registry, boot's start in `cmd/start.go`, the provider page handler and template, a shared recognition helper and billing-line partial used by `member_plans.html`, `member_addons.html` and `index.html`, the view models that feed them, the action-trigger registry, and one field on the form library's `FormView` with its part in `ui_form.html`.
|
||||
- Schema: none. The record is a JSON instance setting, the kind the environment check already uses.
|
||||
- Stripe calls: at most three read calls per boot and per press (the account, or an invoice preview and one charge list). None on any page render.
|
||||
- Docs: `docs/stripe.md` (new section; the paragraph that says the console never reads the account is rewritten), `docs/production-deployment.md` (a pointer in the setup order), the `ConfigSpec` comment in `internal/integrations/stripe/stripe.go`.
|
||||
- Verification: a scripted capture of every scenario at 1280 and 390 pixels into a review sheet beside the change.
|
||||
@@ -0,0 +1,23 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: The Stripe guide states what members see and how to set it
|
||||
|
||||
`docs/stripe.md` SHALL carry a section `What members see` that states:
|
||||
|
||||
- that Stripe Checkout's header, receipts and the card statement carry the account's public business name and statement descriptor, which Stripe fills from the registered legal name until they are set;
|
||||
- where to set the public business name, the statement descriptor and the icon in the Stripe dashboard, and that each sandbox is a separate account holding its own copy;
|
||||
- what the console shows members, and when it shows nothing;
|
||||
- that the console reads these values with the permissions its key already holds, what it reads without Accounts Read, and what Accounts Read adds;
|
||||
- what Accounts Read exposes: on an ordinary account, that account's profile, email, company details and status; on a Connect platform, every connected account as well.
|
||||
|
||||
The guide SHALL NOT say the console never reads the account; it SHALL say the console reads the account when the key permits and never stores its id. `docs/production-deployment.md` SHALL point to the section from its setup order.
|
||||
|
||||
#### Scenario: An operator following the guide sets the public details
|
||||
|
||||
- **WHEN** an operator reads the Stripe guide while setting up a deployment
|
||||
- **THEN** the guide names the three settings, where they live, that each sandbox needs them too, and the optional permission with what it exposes
|
||||
|
||||
#### Scenario: The account-reading statement is accurate
|
||||
|
||||
- **WHEN** the guide describes what the console reads from Stripe
|
||||
- **THEN** it says the account is read when the key permits and its id is never stored
|
||||
@@ -0,0 +1,15 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: A render names an element that describes the commit
|
||||
|
||||
A render MAY name, in `FormView.CommitDescribedBy`, the id of an element outside the form that states a fact about what committing does. The form part SHALL write that id as `aria-describedby` on the enabled submit button and on a commit that is a link out of the console (`FormView.CommitLink`). It SHALL NOT write it on a commit disabled behind a precondition (`FormView.CommitState`), whose disabled control is described by its own reason. When the field is empty, the part SHALL write nothing.
|
||||
|
||||
#### Scenario: The enabled commit references the element
|
||||
|
||||
- **WHEN** a handler renders a preview with `CommitDescribedBy` set to `plans-billing-facts`
|
||||
- **THEN** the submit button carries `aria-describedby="plans-billing-facts"`
|
||||
|
||||
#### Scenario: A disabled commit keeps only its reason
|
||||
|
||||
- **WHEN** the same render also sets `CommitState`
|
||||
- **THEN** the disabled commit references its reason's id and not `plans-billing-facts`
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: The Stripe provider page shows the public details members see
|
||||
|
||||
With Stripe configured, the Stripe provider page SHALL carry a section titled `Public details` between its `Details` section and its `Environment check` section, rendered from the stored public-details record (stripe-integration-infrastructure: "The console reads the public details members see").
|
||||
|
||||
- One resting line, first under the heading, SHALL read `Not read yet.` when no current record exists, `Read Sep 25, 2026 3:04 PM.` in the page's date format when one does, or `API key changed since the last read.` when the stored record carries another fingerprint.
|
||||
- A definition list SHALL hold `Public business name`, `Statement descriptor` and `Icon`, every value as plain text and none as a badge. A name or descriptor recorded as equal to the legal name SHALL be followed by a muted `Same as the legal name.` A descriptor read from a charge SHALL be followed by a muted `As printed on the latest payment.` An unset value SHALL read `Not set`; a set icon SHALL read `Set`.
|
||||
- A field not read SHALL state why in its value cell: `Needs the Accounts Read permission on the API key.`, `Needs the Invoices permission on the API key.`, `Needs the Charges permission on the API key.`, `Read after the account has a recurring price.`, or `Read after the first payment.`
|
||||
- A last row `Shown to members` SHALL always accompany the list. When the member recognition rule (member-product-discovery: "The Products page names who bills the member") shows any sentence, it SHALL hold those exact sentences. Otherwise it SHALL read `Nothing; both match the deployment name.` when both were read, `Nothing; the business name matches the deployment name.` or `Nothing; the statement descriptor matches the deployment name.` when only that one was read, and `Nothing.` when neither was.
|
||||
- One outline control `Read again`, a declared action trigger, SHALL sit in the section header's action slot, post to `/partials/operator/integrations/stripe/public-details`, start the read, wait up to three seconds, and re-render the section's body alone. Its toasts SHALL read `Public details read.`, `Read started.` when the wait ran out, `Read running.` when a run already holds the id, and `Could not read the public details.` on failure, with the error in the log and not in the toast.
|
||||
|
||||
The section SHALL NOT render when Stripe is not configured.
|
||||
|
||||
#### Scenario: The operator sees the legal name noted
|
||||
|
||||
- **WHEN** the record was read from the account and the name equals the legal name
|
||||
- **THEN** the section shows the name followed by `Same as the legal name.`, the descriptor, the icon as `Set` or `Not set`, and a `Shown to members` row with `Billed by …` when the rule shows it
|
||||
|
||||
#### Scenario: The operator sees that members are shown nothing
|
||||
|
||||
- **WHEN** the record's name and descriptor are both recognised against the deployment name
|
||||
- **THEN** the `Shown to members` row reads `Nothing; both match the deployment name.`
|
||||
|
||||
#### Scenario: The operator learns what a minimal key cannot read
|
||||
|
||||
- **WHEN** the record came from the fallback path with no charge yet
|
||||
- **THEN** the name shows as text, the descriptor reads `Read after the first payment.`, and the icon reads `Needs the Accounts Read permission on the API key.`
|
||||
|
||||
#### Scenario: Read again re-renders the section
|
||||
|
||||
- **WHEN** the operator presses `Read again` and the read finishes within three seconds
|
||||
- **THEN** a toast reads `Public details read.` and the section re-renders from the new record with its resting line naming the time
|
||||
|
||||
#### Scenario: Billing off shows no section
|
||||
|
||||
- **WHEN** Stripe is not configured
|
||||
- **THEN** the provider page carries no `Public details` section
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: The successful return repeats the billing facts
|
||||
|
||||
On a `?checkout=success` return, the dashboard's acknowledgement SHALL carry, as a second line inside the same alert, the sentences the Products page's recognition rule shows (member-product-discovery: "The Products page names who bills the member when the name would not be recognised"), under the same rule and from the same record. When the rule shows nothing, the acknowledgement SHALL be unchanged. A `?checkout=cancel` return SHALL carry nothing new.
|
||||
|
||||
#### Scenario: The member returns after paying under an unfamiliar name
|
||||
|
||||
- **WHEN** the member returns with `?checkout=success`, the record's name is "Acme Holdings LLC" and its descriptor "ACMEHOLDINGS", neither recognised
|
||||
- **THEN** the success alert keeps its acknowledgement and adds `Billed by Acme Holdings LLC. Card statement: ACMEHOLDINGS.`
|
||||
|
||||
#### Scenario: Recognised names leave the acknowledgement as it is
|
||||
|
||||
- **WHEN** the member returns with `?checkout=success` and both values are recognised
|
||||
- **THEN** the success alert reads exactly as before this change
|
||||
|
||||
#### Scenario: A cancelled return adds nothing
|
||||
|
||||
- **WHEN** the member returns with `?checkout=cancel`
|
||||
- **THEN** the neutral acknowledgement carries no billing sentence
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: The Products page names who bills the member when the name would not be recognised
|
||||
|
||||
The Products page SHALL tell a member who will bill them, and the text on their card statement, before they leave for Stripe, whenever either would not be recognised against the deployment's own name, and SHALL say nothing when both are recognised.
|
||||
|
||||
**The rule.** From a current public-details record and the deployment name, a comparison key SHALL be the value under NFKC normalisation and Unicode case folding keeping only letters, marks and digits. The recognised keys SHALL be the deployment name's key and, when it leaves a non-empty name, the key of the deployment name without trailing whole words "Member Console". The sentence `Billed by {name}.` SHALL be shown when the name was read and its key is not recognised. The sentence `Card statement: {descriptor}.` SHALL be shown when the descriptor was read and its key is not recognised, independently of the first. A descriptor of 22 characters, Stripe's limit, whose key is the start of a recognised key SHALL count as recognised. No other partial, abbreviation or legal-suffix matching SHALL apply. Values SHALL render as read, escaped.
|
||||
|
||||
**The placement.** The sentences SHALL render together as one muted small paragraph:
|
||||
|
||||
- under the `Plans` heading, as the first element of the plans region, when at least one tier offers an enabled move that costs money: a checkout, or a switch to another paid tier;
|
||||
- under the add-ons section's heading, after its lead sentence when one renders, whenever the section renders.
|
||||
|
||||
The paragraph SHALL appear at most once per section and never inside a card or a table row. Every enabled control that costs money (a checkout's `Subscribe`, `Upgrade` or `Add`, a switch's `Upgrade` or `Downgrade`, and the switch preview's commit, `Confirm switch` or the `Approve payment` link) SHALL reference its section's paragraph with `aria-describedby` when the paragraph is shown. Cancel, keep and disabled controls SHALL NOT reference it.
|
||||
|
||||
#### Scenario: One ladder with an unfamiliar billing name
|
||||
|
||||
- **WHEN** the deployment is named "Riverside Makers", the record's name is "Acme Holdings LLC", the descriptor is not yet read, and a ladder offers Upgrade buttons
|
||||
- **THEN** one line `Billed by Acme Holdings LLC.` sits under the Plans heading, and each Upgrade button is described by it
|
||||
|
||||
#### Scenario: Many ladders and add-ons carry two lines in total
|
||||
|
||||
- **WHEN** six ladders offer checkout buttons and the add-ons section lists four products, with an unfamiliar name and descriptor
|
||||
- **THEN** the page carries exactly two lines, one under the Plans heading and one under the add-ons heading, each reading `Billed by Acme Holdings LLC. Card statement: ACMEHOLDINGS.`
|
||||
|
||||
#### Scenario: Add-ons only
|
||||
|
||||
- **WHEN** the catalog has no plans and the add-ons section renders as `Products`
|
||||
- **THEN** the line sits under that heading and no plans line exists
|
||||
|
||||
#### Scenario: Recognised names add nothing
|
||||
|
||||
- **WHEN** the deployment is named "Riverside Makers Member Console" and the record's name is "Riverside Makers" and the descriptor is "RIVERSIDE MAKERS"
|
||||
- **THEN** no line renders and no control carries `aria-describedby` for it
|
||||
|
||||
#### Scenario: Only the statement text differs
|
||||
|
||||
- **WHEN** the name is recognised and the descriptor "ACMEHOLDINGS" is not
|
||||
- **THEN** the line reads `Card statement: ACMEHOLDINGS.` alone
|
||||
|
||||
#### Scenario: A ladder of switches carries the line
|
||||
|
||||
- **WHEN** the member holds a paid tier, the plans region offers only switch and cancel moves, and the name is not recognised
|
||||
- **THEN** the line renders under the Plans heading, each switch control is described by it, and the cancel control is not
|
||||
|
||||
#### Scenario: The switch preview's commit is described by the line
|
||||
|
||||
- **WHEN** the line is shown and the member opens a switch's preview
|
||||
- **THEN** `Confirm switch` references the line with `aria-describedby`, and so does `Approve payment` when the upgrade awaits the bank's approval
|
||||
|
||||
#### Scenario: A ladder of cancels only carries no line
|
||||
|
||||
- **WHEN** the plans region's only enabled move is a cancel to the free tier
|
||||
- **THEN** no line renders under the Plans heading
|
||||
|
||||
#### Scenario: A descriptor cut at Stripe's limit is recognised
|
||||
|
||||
- **WHEN** the deployment is named "Riverside Makers Cooperative Guild" and the descriptor is "RIVERSIDE MAKERS COOPE", 22 characters
|
||||
- **THEN** no `Card statement` sentence renders; a descriptor "RIVERSIDE MAKERS" under the limit would render one
|
||||
|
||||
#### Scenario: No current record adds nothing
|
||||
|
||||
- **WHEN** no public-details record exists, or it was read under another key
|
||||
- **THEN** no line renders anywhere on the page
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: The console reads the public details members see
|
||||
|
||||
With Stripe configured, the console SHALL read the Stripe account's public business name, its statement descriptor and whether it has an icon, using only the permissions the API key already holds, and SHALL store the outcome for the provider page and the member pages to render. No page render SHALL call Stripe for these values.
|
||||
|
||||
**The workflow.** `StripePublicDetailsWorkflow` SHALL run under the fixed workflow id `stripe-public-details`, one activity, under Temporal's retry policy. Boot SHALL start it on every start with Stripe configured, started and not awaited; a start that fails SHALL be logged and SHALL NOT fail boot. The provider page's `Read again` control SHALL start the same id with `trigger=operator`.
|
||||
|
||||
**The account path.** The activity SHALL first retrieve the account the key belongs to. When Stripe answers, the record SHALL take `business_profile.name` as the name, `settings.payments.statement_descriptor` as the descriptor, and whether `settings.branding.icon` is set as the icon. When the answer carries a legal name (`company.name`, or the individual's first and last name), the record SHALL note, for the name and for the descriptor, whether its comparison key equals the legal name's; the legal name itself SHALL NOT be stored.
|
||||
|
||||
**The fallback path.** When the account retrieval answers HTTP 403, the activity SHALL read each field on its own:
|
||||
|
||||
- the name from an invoice preview (`POST /v1/invoices/create_preview`) for one active recurring price listed from the account, with no customer; the preview's `account_name` is the name;
|
||||
- the descriptor from the most recent charge's `calculated_statement_descriptor`;
|
||||
- the icon and the legal-name comparison are not read.
|
||||
|
||||
A 403 on either fallback read SHALL record that field as not permitted with the permission it needs (`Invoices`, `Charges`); no recurring price, or no charge yet, SHALL record the field as not yet readable. Neither SHALL fail the run. Any other error on any read SHALL fail the activity so Temporal retries it, and the previous record SHALL stay in place until a run succeeds.
|
||||
|
||||
**The record.** The outcome SHALL live in `core.instance_settings` under `stripe.public_details`, a JSON object holding the key fingerprint the read ran under, the time it finished, whether the account answered, and for each of name, descriptor and icon: a state (`read`, `unset`, `not_permitted`, `not_yet`), the value when read, its source (`account`, `invoice_preview`, `latest_charge`), the legal-name comparison when made, and the permission a `not_permitted` field needs. The account id SHALL NOT be stored. A consumer SHALL treat a record whose fingerprint differs from the key in force as no record.
|
||||
|
||||
The activity SHALL log `stripe: public details read` with `account_read`, the three states, `mode`, the fingerprint prefix and the trigger.
|
||||
|
||||
#### Scenario: A key that can read the account records everything
|
||||
|
||||
- **WHEN** the read runs under a key that may retrieve the account, and the account's public name equals its company name
|
||||
- **THEN** the record holds the name, the descriptor and the icon state from the account, with `account_read` true
|
||||
- **AND** the name is marked as equal to the legal name, and the legal name itself is not in the record
|
||||
|
||||
#### Scenario: A minimal key falls back field by field
|
||||
|
||||
- **WHEN** the account retrieval answers 403, the account has an active recurring price, and no charge exists yet
|
||||
- **THEN** the record holds the name from an invoice preview with source `invoice_preview`
|
||||
- **AND** the descriptor is `not_yet`, the icon is `not_permitted` naming Accounts Read, and the run succeeds
|
||||
|
||||
#### Scenario: A refused fallback read does not fail the run
|
||||
|
||||
- **WHEN** the account retrieval and the invoice preview both answer 403
|
||||
- **THEN** the name is `not_permitted` naming Invoices, and the run still reads the descriptor from the latest charge
|
||||
|
||||
#### Scenario: A transient failure keeps the previous record
|
||||
|
||||
- **WHEN** a read fails with anything other than 403
|
||||
- **THEN** the activity fails, Temporal retries it, and the stored record is unchanged until a run succeeds
|
||||
|
||||
#### Scenario: A record from another key is ignored
|
||||
|
||||
- **WHEN** the key changed and the boot-started read has not finished
|
||||
- **THEN** the provider page and the member pages treat the record as absent
|
||||
|
||||
#### Scenario: Boot never waits on Stripe
|
||||
|
||||
- **WHEN** the console boots with Stripe configured and Temporal or Stripe unreachable
|
||||
- **THEN** boot completes, the failed start is logged, and no page shows a public detail
|
||||
@@ -0,0 +1,59 @@
|
||||
## 1. The record and the settings registry (design D3)
|
||||
|
||||
- [x] 1.1 `internal/integrations/stripe/store/public_details.go`: `PublicDetailsRecord` and `PublicDetail` (state, value, source, legal_name, permission), the state and source constants, `DecodePublicDetailsRecord(raw, found)` in the shape of `DecodeEnvironmentCheckRecord`, and `Current(fingerprint)` reporting whether the record belongs to the key in force.
|
||||
- [x] 1.2 `internal/instance/settings.go`: declare `StripePublicDetails Key = "stripe.public_details"` as `kindJSON` in the registry.
|
||||
- [x] 1.3 Unit tests: decode of a missing, unreadable and valid record; `Current` against a matching and a differing fingerprint.
|
||||
|
||||
## 2. The read (design D1, D2)
|
||||
|
||||
- [x] 2.1 `internal/integrations/stripe/workflows/public_details.go`: `StripePublicDetailsWorkflow` under the fixed id `stripe-public-details`, one activity with a one-minute attempt budget and a retry policy, `StartPublicDetailsRead(ctx, c, taskQueue, trigger)`, and `PublicDetailsActivities{DB, Logger, Mode, Fingerprint}`.
|
||||
- [x] 2.2 The activity's account path: `account.Get()`; name, descriptor, icon; the legal-name comparison against `company.name` or the individual's name through the comparison key of design D4 (the key function lives in the Stripe store so both trees share it).
|
||||
- [x] 2.3 The fallback path on HTTP 403: `price.List` (active, recurring, limit 1) then `invoice.CreatePreview` with that price and no customer for the name; `charge.List` (limit 1) for `calculated_statement_descriptor`; each refusal or absence recorded per field, never failing the run; any non-403 error failing the activity.
|
||||
- [x] 2.4 Write the record under `stripe.public_details` with the fingerprint and `read_at`; log `stripe: public details read` with the fields design D3 names.
|
||||
- [x] 2.5 Register the workflow and the activities in `stripe.Adapter.RegisterWorkflows`; start the read at every boot with Stripe configured in `cmd/start.go`, not awaited, a failed start logged.
|
||||
- [x] 2.6 Activity tests against a fake Stripe backend: the account path with and without a legal-name match; the fallback with a price and no charge; a 403 on the preview; a 403 on the charge list; no recurring price; a 500 failing the activity and leaving the record unchanged.
|
||||
|
||||
## 3. Recognition (design D4)
|
||||
|
||||
- [x] 3.1 `golang.org/x/text` becomes a direct dependency; `ComparisonKey` (NFKC, case fold, letters, marks and digits) in the Stripe store.
|
||||
- [x] 3.2 `internal/server/billing_facts.go`: `billingFacts(record, fingerprint, deploymentName)` returning the two sentences under the rule, and the recognised-keys derivation including the trailing "Member Console" strip.
|
||||
- [x] 3.3 Table test covering every row of the design's rule: match, trailing-words match, the default name, descriptor only, name only, both, unknown descriptor, a record under another key, no record, and a near miss that shows the line.
|
||||
- [x] 3.4 A `billingFacts` template partial rendering the sentences as one `text-muted small` paragraph with the id it is given.
|
||||
|
||||
## 4. The Products page and the return (design D5)
|
||||
|
||||
- [x] 4.1 `PlansData` and `AddonsData` carry the facts; `member_plans.html` renders the paragraph `plans-billing-facts` first inside the region when a tier has an enabled checkout move; `member_addons.html` renders `addons-billing-facts` after the header and lead.
|
||||
- [x] 4.2 Every enabled checkout control in both partials carries `aria-describedby` naming its section's paragraph when it is shown; switch, cancel and disabled controls do not.
|
||||
- [x] 4.3 `IndexPageData` carries the facts on a `success` return; `index.html` adds them as a second line inside the success alert.
|
||||
- [x] 4.4 The member handlers and the dashboard get the record and the key fingerprint through the existing handler wiring; one instance-setting read per render, no Stripe call.
|
||||
- [x] 4.5 Render tests: plans with a checkout move show the paragraph and the references; a switch-only region shows none; add-ons show theirs; recognised names show nothing; the success alert with and without facts; a cancel return unchanged.
|
||||
|
||||
## 5. The provider page (design D6)
|
||||
|
||||
- [x] 5.1 `StripeIntegrationData` gains the `Public details` section's view model built from the record: rows, badges, not-read reasons, the `Products page` row from `billingFacts`, the resting line.
|
||||
- [x] 5.2 `operator_integration_stripe_public_details.html` with the id the control re-renders; placed between `Details` and `Environment check`, only when Stripe is configured.
|
||||
- [x] 5.3 `PostStripePublicDetails` at `/partials/operator/integrations/stripe/public-details`: start, three-second wait, the four toasts, re-render; route registration and the action-trigger declaration `Read again`.
|
||||
- [x] 5.4 Handler and render tests for each state: account read with legal-name badges and an unset icon, fallback before the first payment, fallback with the descriptor from a charge, not permitted fields, no record, another key's record, billing off.
|
||||
|
||||
## 6. Documentation (design D7)
|
||||
|
||||
- [x] 6.1 `docs/stripe.md`: the `What members see` section; the configuration paragraph rewritten to say the console reads the account when the key permits and never stores its id.
|
||||
- [x] 6.2 `docs/production-deployment.md`: a pointer to the section from the setup order.
|
||||
- [x] 6.3 The `ConfigSpec` comment in `internal/integrations/stripe/stripe.go` rewritten to match.
|
||||
|
||||
## 7. Verification (design D8)
|
||||
|
||||
- [x] 7.1 `test/e2e/merchant-disclosure/`: the scenario capture, skipped unless `MERCHANT_SHEET=1`, resetting the application database before and after, seeding each scenario, checking its lines, and writing `test/screens/merchant-disclosure/sheet.html` at 1280 and 390: one ladder; six ladders with add-ons; add-ons only; recognised names; descriptor known and unknown; name only; the success return; the cancel return; a switch-only region; the provider page in the account-read, fallback and no-record states.
|
||||
- [x] 7.2 Real reads against the sandbox: the console under the restricted test key and under a full test key, `Read again` pressed, both provider pages captured into the same folder; the billing-off provider page and Products page captured with Stripe unset.
|
||||
- [x] 7.3 `make lint`, `make test`, `make screens`; name the changed screens; the maintainer reviews the sheets.
|
||||
|
||||
## 8. The maintainer's sheet review (2026-09-27)
|
||||
|
||||
- [x] 8.1 The provider page drops its badges: a value equal to the legal name is followed by a muted `Same as the legal name.`; an unset value reads `Not set` and a set icon `Set`, as plain text. The `legal_name` badge state leaves the map.
|
||||
- [x] 8.2 The `Products page` row becomes `Shown to members`, always present with the list: the sentences, or `Nothing; …` naming which details matched, or `Nothing.`
|
||||
- [x] 8.3 `From the latest payment.` reads `As printed on the latest payment.`
|
||||
- [x] 8.4 `Read again` and `Check now` move into their section headers as the header's refresh action (`Link.Refresh`, no opener glyph); each section's resting line comes first in its body. `docs/design-system.md` documents the action.
|
||||
- [x] 8.5 A switch counts as a move that costs money: the plans line shows for an enabled switch, and switch controls reference it; cancel, keep and disabled controls do not.
|
||||
- [x] 8.6 A descriptor at Stripe's 22-character limit whose key starts a recognised key counts as recognised; table cases for the cut descriptor, a divergent cut, a start under the limit, and a business name, which has no limit.
|
||||
- [x] 8.7 The sheet re-run with `MERCHANT_SHEET=1`, its scenario checks updated for 8.1 to 8.5; then 7.3. The cut descriptor renders like the recognised scenario, so it stays in the table test.
|
||||
- [x] 8.8 The switch preview's commit, `Confirm switch` or `Approve payment`, references the plans line: `FormView.CommitDescribedBy` in the form library, written on an enabled or link commit and never on a disabled one; `PlansData.WithPreview` sets it on both preview renders. Part and preview render tests. Supersedes 4.2's "switch controls do not".
|
||||
@@ -3,9 +3,7 @@
|
||||
## Purpose
|
||||
|
||||
The README-to-production path: what a deployer needs, in what order, with every configuration key documented, so running an instance requires reading, not archaeology.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: The README states what the system needs and the order to set it up
|
||||
|
||||
The repository README SHALL state, before any development-stack material: what the product is; its load-bearing external dependencies, with the FedWiki farm named up front alongside the OIDC identity provider, PostgreSQL, Valkey, Temporal, and (optional, for paid products) Stripe; and the first-run order — deploy, sign in as an operator, then follow the in-app setup checklist. The README SHALL link the production deployment guide. A reader deciding whether they can run this SHALL NOT need to leave the README to learn the dependency list or the setup order.
|
||||
@@ -43,3 +41,26 @@ The documentation SHALL include a configuration reference in which every configu
|
||||
variable
|
||||
- **THEN** that key appears in the configuration reference with purpose,
|
||||
default, required/optional status, and its environment override name
|
||||
|
||||
### Requirement: The Stripe guide states what members see and how to set it
|
||||
|
||||
`docs/stripe.md` SHALL carry a section `What members see` that states:
|
||||
|
||||
- that Stripe Checkout's header, receipts and the card statement carry the account's public business name and statement descriptor, which Stripe fills from the registered legal name until they are set;
|
||||
- where to set the public business name, the statement descriptor and the icon in the Stripe dashboard, and that each sandbox is a separate account holding its own copy;
|
||||
- what the console shows members, and when it shows nothing;
|
||||
- that the console reads these values with the permissions its key already holds, what it reads without Accounts Read, and what Accounts Read adds;
|
||||
- what Accounts Read exposes: on an ordinary account, that account's profile, email, company details and status; on a Connect platform, every connected account as well.
|
||||
|
||||
The guide SHALL NOT say the console never reads the account; it SHALL say the console reads the account when the key permits and never stores its id. `docs/production-deployment.md` SHALL point to the section from its setup order.
|
||||
|
||||
#### Scenario: An operator following the guide sets the public details
|
||||
|
||||
- **WHEN** an operator reads the Stripe guide while setting up a deployment
|
||||
- **THEN** the guide names the three settings, where they live, that each sandbox needs them too, and the optional permission with what it exposes
|
||||
|
||||
#### Scenario: The account-reading statement is accurate
|
||||
|
||||
- **WHEN** the guide describes what the console reads from Stripe
|
||||
- **THEN** it says the account is read when the key permits and its id is never stored
|
||||
|
||||
|
||||
@@ -258,3 +258,18 @@ The `form` part SHALL render a Dense-family form's row with its columns aligned
|
||||
|
||||
- **WHEN** a sub-record form is refused with an error on one of its controls
|
||||
- **THEN** the error renders under that control inside its column, no error block renders after the row, and the commit sits on the control line
|
||||
|
||||
### Requirement: A render names an element that describes the commit
|
||||
|
||||
A render MAY name, in `FormView.CommitDescribedBy`, the id of an element outside the form that states a fact about what committing does. The form part SHALL write that id as `aria-describedby` on the enabled submit button and on a commit that is a link out of the console (`FormView.CommitLink`). It SHALL NOT write it on a commit disabled behind a precondition (`FormView.CommitState`), whose disabled control is described by its own reason. When the field is empty, the part SHALL write nothing.
|
||||
|
||||
#### Scenario: The enabled commit references the element
|
||||
|
||||
- **WHEN** a handler renders a preview with `CommitDescribedBy` set to `plans-billing-facts`
|
||||
- **THEN** the submit button carries `aria-describedby="plans-billing-facts"`
|
||||
|
||||
#### Scenario: A disabled commit keeps only its reason
|
||||
|
||||
- **WHEN** the same render also sets `CommitState`
|
||||
- **THEN** the disabled commit references its reason's id and not `plans-billing-facts`
|
||||
|
||||
|
||||
@@ -404,3 +404,40 @@ remediation a secret override produces (`member-console config clear <key>`).
|
||||
`member-console config clear fedwiki-farm-api-url`, and no value from the
|
||||
row SHALL be applied
|
||||
|
||||
### Requirement: The Stripe provider page shows the public details members see
|
||||
|
||||
With Stripe configured, the Stripe provider page SHALL carry a section titled `Public details` between its `Details` section and its `Environment check` section, rendered from the stored public-details record (stripe-integration-infrastructure: "The console reads the public details members see").
|
||||
|
||||
- One resting line, first under the heading, SHALL read `Not read yet.` when no current record exists, `Read Sep 25, 2026 3:04 PM.` in the page's date format when one does, or `API key changed since the last read.` when the stored record carries another fingerprint.
|
||||
- A definition list SHALL hold `Public business name`, `Statement descriptor` and `Icon`, every value as plain text and none as a badge. A name or descriptor recorded as equal to the legal name SHALL be followed by a muted `Same as the legal name.` A descriptor read from a charge SHALL be followed by a muted `As printed on the latest payment.` An unset value SHALL read `Not set`; a set icon SHALL read `Set`.
|
||||
- A field not read SHALL state why in its value cell: `Needs the Accounts Read permission on the API key.`, `Needs the Invoices permission on the API key.`, `Needs the Charges permission on the API key.`, `Read after the account has a recurring price.`, or `Read after the first payment.`
|
||||
- A last row `Shown to members` SHALL always accompany the list. When the member recognition rule (member-product-discovery: "The Products page names who bills the member") shows any sentence, it SHALL hold those exact sentences. Otherwise it SHALL read `Nothing; both match the deployment name.` when both were read, `Nothing; the business name matches the deployment name.` or `Nothing; the statement descriptor matches the deployment name.` when only that one was read, and `Nothing.` when neither was.
|
||||
- One outline control `Read again`, a declared action trigger, SHALL sit in the section header's action slot, post to `/partials/operator/integrations/stripe/public-details`, start the read, wait up to three seconds, and re-render the section's body alone. Its toasts SHALL read `Public details read.`, `Read started.` when the wait ran out, `Read running.` when a run already holds the id, and `Could not read the public details.` on failure, with the error in the log and not in the toast.
|
||||
|
||||
The section SHALL NOT render when Stripe is not configured.
|
||||
|
||||
#### Scenario: The operator sees the legal name noted
|
||||
|
||||
- **WHEN** the record was read from the account and the name equals the legal name
|
||||
- **THEN** the section shows the name followed by `Same as the legal name.`, the descriptor, the icon as `Set` or `Not set`, and a `Shown to members` row with `Billed by …` when the rule shows it
|
||||
|
||||
#### Scenario: The operator sees that members are shown nothing
|
||||
|
||||
- **WHEN** the record's name and descriptor are both recognised against the deployment name
|
||||
- **THEN** the `Shown to members` row reads `Nothing; both match the deployment name.`
|
||||
|
||||
#### Scenario: The operator learns what a minimal key cannot read
|
||||
|
||||
- **WHEN** the record came from the fallback path with no charge yet
|
||||
- **THEN** the name shows as text, the descriptor reads `Read after the first payment.`, and the icon reads `Needs the Accounts Read permission on the API key.`
|
||||
|
||||
#### Scenario: Read again re-renders the section
|
||||
|
||||
- **WHEN** the operator presses `Read again` and the read finishes within three seconds
|
||||
- **THEN** a toast reads `Public details read.` and the section re-renders from the new record with its resting line naming the time
|
||||
|
||||
#### Scenario: Billing off shows no section
|
||||
|
||||
- **WHEN** Stripe is not configured
|
||||
- **THEN** the provider page carries no `Public details` section
|
||||
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
## Purpose
|
||||
|
||||
Enable an authenticated member to initiate a plan upgrade from the Products page by submitting a plan's price to Stripe Checkout via HTMX, and to be returned to a post-checkout acknowledgement that tolerates asynchronous webhook-driven fulfillment. Current-plan determination is derived from active ladder attachments so the upgrade control is shown only for non-current, purchasable plans.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Member can initiate upgrade checkout from a plan card
|
||||
|
||||
The system SHALL render an active "Upgrade" control on each plan card that is not the member's current plan and that is purchasable. Activating the control SHALL submit the plan's `price_id` to the existing `POST /billing/checkout` endpoint via an HTMX request, the request shape every mutation form takes (`form-conventions`), because the endpoint answers it with an `HX-Redirect` a native form POST could not consume. For an HTMX request the endpoint SHALL respond with an `HX-Redirect` header carrying the Stripe Checkout URL, causing a full-page navigation to Stripe's hosted Checkout page. The control SHALL use HTMX attributes only (no inline script), keeping it CSP-clean.
|
||||
@@ -70,3 +68,23 @@ The set of the member's current plans SHALL be derived from the pool's active la
|
||||
|
||||
- **WHEN** a pool's active attachment was created by any provision source (grant, subscription, or purchase)
|
||||
- **THEN** the referenced product SHALL be treated as current on the plans page
|
||||
|
||||
### Requirement: The successful return repeats the billing facts
|
||||
|
||||
On a `?checkout=success` return, the dashboard's acknowledgement SHALL carry, as a second line inside the same alert, the sentences the Products page's recognition rule shows (member-product-discovery: "The Products page names who bills the member when the name would not be recognised"), under the same rule and from the same record. When the rule shows nothing, the acknowledgement SHALL be unchanged. A `?checkout=cancel` return SHALL carry nothing new.
|
||||
|
||||
#### Scenario: The member returns after paying under an unfamiliar name
|
||||
|
||||
- **WHEN** the member returns with `?checkout=success`, the record's name is "Acme Holdings LLC" and its descriptor "ACMEHOLDINGS", neither recognised
|
||||
- **THEN** the success alert keeps its acknowledgement and adds `Billed by Acme Holdings LLC. Card statement: ACMEHOLDINGS.`
|
||||
|
||||
#### Scenario: Recognised names leave the acknowledgement as it is
|
||||
|
||||
- **WHEN** the member returns with `?checkout=success` and both values are recognised
|
||||
- **THEN** the success alert reads exactly as before this change
|
||||
|
||||
#### Scenario: A cancelled return adds nothing
|
||||
|
||||
- **WHEN** the member returns with `?checkout=cancel`
|
||||
- **THEN** the neutral acknowledgement carries no billing sentence
|
||||
|
||||
|
||||
@@ -290,3 +290,66 @@ A rank-0 tier with no price SHALL render no purchase control and no "Not availab
|
||||
- **WHEN** a member views a priced tier whose price is not synced
|
||||
- **THEN** the card's move control is disabled with "Not available for purchase yet"
|
||||
|
||||
### Requirement: The Products page names who bills the member when the name would not be recognised
|
||||
|
||||
The Products page SHALL tell a member who will bill them, and the text on their card statement, before they leave for Stripe, whenever either would not be recognised against the deployment's own name, and SHALL say nothing when both are recognised.
|
||||
|
||||
**The rule.** From a current public-details record and the deployment name, a comparison key SHALL be the value under NFKC normalisation and Unicode case folding keeping only letters, marks and digits. The recognised keys SHALL be the deployment name's key and, when it leaves a non-empty name, the key of the deployment name without trailing whole words "Member Console". The sentence `Billed by {name}.` SHALL be shown when the name was read and its key is not recognised. The sentence `Card statement: {descriptor}.` SHALL be shown when the descriptor was read and its key is not recognised, independently of the first. A descriptor of 22 characters, Stripe's limit, whose key is the start of a recognised key SHALL count as recognised. No other partial, abbreviation or legal-suffix matching SHALL apply. Values SHALL render as read, escaped.
|
||||
|
||||
**The placement.** The sentences SHALL render together as one muted small paragraph:
|
||||
|
||||
- under the `Plans` heading, as the first element of the plans region, when at least one tier offers an enabled move that costs money: a checkout, or a switch to another paid tier;
|
||||
- under the add-ons section's heading, after its lead sentence when one renders, whenever the section renders.
|
||||
|
||||
The paragraph SHALL appear at most once per section and never inside a card or a table row. Every enabled control that costs money (a checkout's `Subscribe`, `Upgrade` or `Add`, a switch's `Upgrade` or `Downgrade`, and the switch preview's commit, `Confirm switch` or the `Approve payment` link) SHALL reference its section's paragraph with `aria-describedby` when the paragraph is shown. Cancel, keep and disabled controls SHALL NOT reference it.
|
||||
|
||||
#### Scenario: One ladder with an unfamiliar billing name
|
||||
|
||||
- **WHEN** the deployment is named "Riverside Makers", the record's name is "Acme Holdings LLC", the descriptor is not yet read, and a ladder offers Upgrade buttons
|
||||
- **THEN** one line `Billed by Acme Holdings LLC.` sits under the Plans heading, and each Upgrade button is described by it
|
||||
|
||||
#### Scenario: Many ladders and add-ons carry two lines in total
|
||||
|
||||
- **WHEN** six ladders offer checkout buttons and the add-ons section lists four products, with an unfamiliar name and descriptor
|
||||
- **THEN** the page carries exactly two lines, one under the Plans heading and one under the add-ons heading, each reading `Billed by Acme Holdings LLC. Card statement: ACMEHOLDINGS.`
|
||||
|
||||
#### Scenario: Add-ons only
|
||||
|
||||
- **WHEN** the catalog has no plans and the add-ons section renders as `Products`
|
||||
- **THEN** the line sits under that heading and no plans line exists
|
||||
|
||||
#### Scenario: Recognised names add nothing
|
||||
|
||||
- **WHEN** the deployment is named "Riverside Makers Member Console" and the record's name is "Riverside Makers" and the descriptor is "RIVERSIDE MAKERS"
|
||||
- **THEN** no line renders and no control carries `aria-describedby` for it
|
||||
|
||||
#### Scenario: Only the statement text differs
|
||||
|
||||
- **WHEN** the name is recognised and the descriptor "ACMEHOLDINGS" is not
|
||||
- **THEN** the line reads `Card statement: ACMEHOLDINGS.` alone
|
||||
|
||||
#### Scenario: A ladder of switches carries the line
|
||||
|
||||
- **WHEN** the member holds a paid tier, the plans region offers only switch and cancel moves, and the name is not recognised
|
||||
- **THEN** the line renders under the Plans heading, each switch control is described by it, and the cancel control is not
|
||||
|
||||
#### Scenario: The switch preview's commit is described by the line
|
||||
|
||||
- **WHEN** the line is shown and the member opens a switch's preview
|
||||
- **THEN** `Confirm switch` references the line with `aria-describedby`, and so does `Approve payment` when the upgrade awaits the bank's approval
|
||||
|
||||
#### Scenario: A ladder of cancels only carries no line
|
||||
|
||||
- **WHEN** the plans region's only enabled move is a cancel to the free tier
|
||||
- **THEN** no line renders under the Plans heading
|
||||
|
||||
#### Scenario: A descriptor cut at Stripe's limit is recognised
|
||||
|
||||
- **WHEN** the deployment is named "Riverside Makers Cooperative Guild" and the descriptor is "RIVERSIDE MAKERS COOPE", 22 characters
|
||||
- **THEN** no `Card statement` sentence renders; a descriptor "RIVERSIDE MAKERS" under the limit would render one
|
||||
|
||||
#### Scenario: No current record adds nothing
|
||||
|
||||
- **WHEN** no public-details record exists, or it was read under another key
|
||||
- **THEN** no line renders anywhere on the page
|
||||
|
||||
|
||||
@@ -296,3 +296,55 @@ The control's success toast reads `36 checked, 2 stale.` when the workflow finis
|
||||
- **WHEN** the workflow start fails at boot
|
||||
- **THEN** boot logs the failure and continues, and the page's control starts the check later
|
||||
|
||||
### Requirement: The console reads the public details members see
|
||||
|
||||
With Stripe configured, the console SHALL read the Stripe account's public business name, its statement descriptor and whether it has an icon, using only the permissions the API key already holds, and SHALL store the outcome for the provider page and the member pages to render. No page render SHALL call Stripe for these values.
|
||||
|
||||
**The workflow.** `StripePublicDetailsWorkflow` SHALL run under the fixed workflow id `stripe-public-details`, one activity, under Temporal's retry policy. Boot SHALL start it on every start with Stripe configured, started and not awaited; a start that fails SHALL be logged and SHALL NOT fail boot. The provider page's `Read again` control SHALL start the same id with `trigger=operator`.
|
||||
|
||||
**The account path.** The activity SHALL first retrieve the account the key belongs to. When Stripe answers, the record SHALL take `business_profile.name` as the name, `settings.payments.statement_descriptor` as the descriptor, and whether `settings.branding.icon` is set as the icon. When the answer carries a legal name (`company.name`, or the individual's first and last name), the record SHALL note, for the name and for the descriptor, whether its comparison key equals the legal name's; the legal name itself SHALL NOT be stored.
|
||||
|
||||
**The fallback path.** When the account retrieval answers HTTP 403, the activity SHALL read each field on its own:
|
||||
|
||||
- the name from an invoice preview (`POST /v1/invoices/create_preview`) for one active recurring price listed from the account, with no customer; the preview's `account_name` is the name;
|
||||
- the descriptor from the most recent charge's `calculated_statement_descriptor`;
|
||||
- the icon and the legal-name comparison are not read.
|
||||
|
||||
A 403 on either fallback read SHALL record that field as not permitted with the permission it needs (`Invoices`, `Charges`); no recurring price, or no charge yet, SHALL record the field as not yet readable. Neither SHALL fail the run. Any other error on any read SHALL fail the activity so Temporal retries it, and the previous record SHALL stay in place until a run succeeds.
|
||||
|
||||
**The record.** The outcome SHALL live in `core.instance_settings` under `stripe.public_details`, a JSON object holding the key fingerprint the read ran under, the time it finished, whether the account answered, and for each of name, descriptor and icon: a state (`read`, `unset`, `not_permitted`, `not_yet`), the value when read, its source (`account`, `invoice_preview`, `latest_charge`), the legal-name comparison when made, and the permission a `not_permitted` field needs. The account id SHALL NOT be stored. A consumer SHALL treat a record whose fingerprint differs from the key in force as no record.
|
||||
|
||||
The activity SHALL log `stripe: public details read` with `account_read`, the three states, `mode`, the fingerprint prefix and the trigger.
|
||||
|
||||
#### Scenario: A key that can read the account records everything
|
||||
|
||||
- **WHEN** the read runs under a key that may retrieve the account, and the account's public name equals its company name
|
||||
- **THEN** the record holds the name, the descriptor and the icon state from the account, with `account_read` true
|
||||
- **AND** the name is marked as equal to the legal name, and the legal name itself is not in the record
|
||||
|
||||
#### Scenario: A minimal key falls back field by field
|
||||
|
||||
- **WHEN** the account retrieval answers 403, the account has an active recurring price, and no charge exists yet
|
||||
- **THEN** the record holds the name from an invoice preview with source `invoice_preview`
|
||||
- **AND** the descriptor is `not_yet`, the icon is `not_permitted` naming Accounts Read, and the run succeeds
|
||||
|
||||
#### Scenario: A refused fallback read does not fail the run
|
||||
|
||||
- **WHEN** the account retrieval and the invoice preview both answer 403
|
||||
- **THEN** the name is `not_permitted` naming Invoices, and the run still reads the descriptor from the latest charge
|
||||
|
||||
#### Scenario: A transient failure keeps the previous record
|
||||
|
||||
- **WHEN** a read fails with anything other than 403
|
||||
- **THEN** the activity fails, Temporal retries it, and the stored record is unchanged until a run succeeds
|
||||
|
||||
#### Scenario: A record from another key is ignored
|
||||
|
||||
- **WHEN** the key changed and the boot-started read has not finished
|
||||
- **THEN** the provider page and the member pages treat the record as absent
|
||||
|
||||
#### Scenario: Boot never waits on Stripe
|
||||
|
||||
- **WHEN** the console boots with Stripe configured and Temporal or Stripe unreachable
|
||||
- **THEN** boot completes, the failed start is logged, and no page shows a public detail
|
||||
|
||||
|
||||
@@ -0,0 +1,719 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
// Package merchantdisclosure_test captures every scenario of the
|
||||
// merchant-disclosure change (design D8) into one contact sheet: the
|
||||
// Products page and the dashboard return a member sees, and the Stripe
|
||||
// provider page's Public details section an operator sees, each at desktop
|
||||
// and mobile width, with the lines each scenario should and should not show
|
||||
// checked on the page as it is captured.
|
||||
//
|
||||
// It is a capture, not a gate. It runs only with MERCHANT_SHEET=1, against
|
||||
// the stack in test/.env with the app running and Stripe configured, and it
|
||||
// clobbers the application database the way `make screens` does: it resets
|
||||
// it to the demo snapshot before seeding and again when it ends. With
|
||||
// MERCHANT_SHEET_KEEP=1 the final reset is skipped and the database is left
|
||||
// holding six ladders, four add-ons and an unfamiliar record, so the same
|
||||
// catalog can be captured by hand with Stripe unset.
|
||||
//
|
||||
// MERCHANT_SHEET=1 go test ./test/e2e/merchant-disclosure/ -count=1 -v
|
||||
//
|
||||
// The sheet lands in test/screens/merchant-disclosure/sheet.html, beside the
|
||||
// capture utility's out/ folder rather than in it, because `make screens`
|
||||
// empties out/ on every run. PNGs captured by hand into the same folder
|
||||
// (real-*.png, billing-off-*.png) are listed at the end of the sheet.
|
||||
package merchantdisclosure_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/google/uuid"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
|
||||
stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/test/e2e/browsertest"
|
||||
)
|
||||
|
||||
var sheetDir = filepath.Join("..", "..", "screens", "merchant-disclosure")
|
||||
|
||||
type width struct {
|
||||
Name string
|
||||
W, H int
|
||||
Scale float64
|
||||
Mobile bool
|
||||
}
|
||||
|
||||
var widths = []width{
|
||||
{Name: "desktop", W: 1280, H: 900, Scale: 1},
|
||||
{Name: "mobile", W: 390, H: 844, Scale: 2, Mobile: true},
|
||||
}
|
||||
|
||||
// The names the unfamiliar records carry. Neither resembles any
|
||||
// deployment name, so the rule shows both.
|
||||
const (
|
||||
otherName = "Northwind Holdings LLC"
|
||||
otherDescriptor = "NORTHWIND HLDGS"
|
||||
)
|
||||
|
||||
type product struct {
|
||||
key, name, about string
|
||||
cents int // 0: no price, the free rung
|
||||
}
|
||||
|
||||
type ladder struct {
|
||||
key, name string
|
||||
tiers []product
|
||||
}
|
||||
|
||||
// The five ladders that join the demo ladder for the many-ladders
|
||||
// scenario, and the four add-ons. Keys carry the sheet_ prefix; the final
|
||||
// reset removes them with everything else.
|
||||
var extraLadders = []ladder{
|
||||
{"sheet_hosting", "Hosting", []product{
|
||||
{"sheet_hosting_s", "Small site", "One site, 5 GB.", 500},
|
||||
{"sheet_hosting_m", "Medium site", "Three sites, 20 GB.", 1200},
|
||||
{"sheet_hosting_l", "Large site", "Ten sites, 100 GB.", 2500},
|
||||
}},
|
||||
{"sheet_storage", "Storage", []product{
|
||||
{"sheet_storage_50", "50 GB", "", 300},
|
||||
{"sheet_storage_200", "200 GB", "", 800},
|
||||
}},
|
||||
{"sheet_domains", "Domains", []product{
|
||||
{"sheet_domains_1", "One domain", "", 200},
|
||||
{"sheet_domains_5", "Five domains", "", 600},
|
||||
{"sheet_domains_u", "Unlimited domains", "", 1500},
|
||||
}},
|
||||
{"sheet_support", "Support", []product{
|
||||
{"sheet_support_c", "Community", "Forum answers.", 0},
|
||||
{"sheet_support_p", "Priority", "Replies within a day.", 2000},
|
||||
{"sheet_support_d", "Dedicated", "A named contact.", 8000},
|
||||
{"sheet_support_e", "Enterprise", "A contract and a service level.", 20000},
|
||||
}},
|
||||
{"sheet_backups", "Backups", []product{
|
||||
{"sheet_backups_w", "Weekly", "", 400},
|
||||
{"sheet_backups_d", "Daily", "", 900},
|
||||
}},
|
||||
}
|
||||
|
||||
var addons = []product{
|
||||
{"sheet_addon_seat", "Extra seat", "One more member on the workspace.", 400},
|
||||
{"sheet_addon_theme", "Custom theme", "", 600},
|
||||
{"sheet_addon_export", "Archive export", "", 300},
|
||||
{"sheet_addon_stats", "Analytics", "", 700},
|
||||
}
|
||||
|
||||
// The demo ladder is the one-ladder scenario's ladder: every demo member
|
||||
// sits on its free rung, so its paid rungs offer Upgrade, the checkout
|
||||
// move the spec's first scenario names.
|
||||
var demoTiers = []string{"demo_plan", "demo_plan_2", "demo_plan_3"}
|
||||
|
||||
type shot struct {
|
||||
section, name, title, about string
|
||||
files map[string]string
|
||||
}
|
||||
|
||||
type sheet struct {
|
||||
t *testing.T
|
||||
ctx context.Context
|
||||
db *sql.DB
|
||||
base string
|
||||
op, member *rod.Page
|
||||
brand string
|
||||
shots []shot
|
||||
demoLadder string
|
||||
}
|
||||
|
||||
func TestMerchantDisclosureSheet(t *testing.T) {
|
||||
if os.Getenv("MERCHANT_SHEET") != "1" {
|
||||
t.Skip("set MERCHANT_SHEET=1 to capture the merchant-disclosure scenarios; the run resets the application database")
|
||||
}
|
||||
base := browsertest.BaseURL(t)
|
||||
dsn := browsertest.EnvFromTestDir(t)["MC_DB_DSN"]
|
||||
if dsn == "" {
|
||||
t.Skip("MC_DB_DSN not set in test/.env")
|
||||
}
|
||||
|
||||
resetAppDB(t)
|
||||
if os.Getenv("MERCHANT_SHEET_KEEP") != "1" {
|
||||
t.Cleanup(func() { resetAppDB(t) })
|
||||
}
|
||||
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open application database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := os.MkdirAll(sheetDir, 0o755); err != nil {
|
||||
t.Fatalf("create %s: %v", sheetDir, err)
|
||||
}
|
||||
|
||||
s := &sheet{t: t, ctx: context.Background(), db: db, base: base}
|
||||
browser := browsertest.NewBrowser(t)
|
||||
s.op = browser.MustIncognito().MustPage()
|
||||
browsertest.LoginAsOperator(t, s.op, base, "/operator/integrations/stripe")
|
||||
if !s.op.MustHas("#stripe-public-details") {
|
||||
t.Skip("Stripe is unconfigured on this stack, so there is no Public details section and no record to vary")
|
||||
}
|
||||
s.brand = strings.TrimSpace(s.op.MustElement(".navbar-brand").MustText())
|
||||
|
||||
// The provider page before any read, then the key's own read. The read
|
||||
// is the one real Stripe call the sheet makes; its record carries the
|
||||
// fingerprint of the key in force, which every seeded record reuses.
|
||||
s.provider("provider-no-record", "No read yet",
|
||||
"The database was just reset, so no record exists. The section says so; Read again sits in its header.",
|
||||
"Not read yet.")
|
||||
fingerprint := s.readAgain()
|
||||
s.provider("provider-live-read", "The key's own read",
|
||||
"Read again pressed against the sandbox with the stack's key. What this shows depends on that key's permissions.",
|
||||
"Public business name")
|
||||
|
||||
s.seedCatalog()
|
||||
s.member = browser.MustIncognito().MustPage()
|
||||
browsertest.LoginAs(t, s.member, base, "/products", "diana")
|
||||
|
||||
both := "Billed by " + otherName + ". Card statement: " + otherDescriptor + "."
|
||||
unfamiliar := record(fingerprint, false,
|
||||
read(otherName, stripedb.SourceInvoicePreview),
|
||||
read(otherDescriptor, stripedb.SourceLatestCharge),
|
||||
notPermitted(stripedb.PermissionAccounts))
|
||||
recognised := record(fingerprint, false,
|
||||
read(s.brand, stripedb.SourceInvoicePreview),
|
||||
read(strings.ToUpper(s.brand), stripedb.SourceLatestCharge),
|
||||
notPermitted(stripedb.PermissionAccounts))
|
||||
nameOnly := record(fingerprint, false,
|
||||
read(otherName, stripedb.SourceInvoicePreview),
|
||||
notYet(stripedb.SourceLatestCharge),
|
||||
notPermitted(stripedb.PermissionAccounts))
|
||||
descriptorOnly := record(fingerprint, false,
|
||||
read(s.brand, stripedb.SourceInvoicePreview),
|
||||
read(otherDescriptor, stripedb.SourceLatestCharge),
|
||||
notPermitted(stripedb.PermissionAccounts))
|
||||
anotherKey := unfamiliar
|
||||
anotherKey.KeyFingerprint = strings.Repeat("0", 64)
|
||||
|
||||
s.products("one-ladder", "One ladder", "one", &unfamiliar,
|
||||
"One ladder whose paid rungs offer Upgrade; the name and the descriptor are both unfamiliar. One line under Plans; every Upgrade button is described by it.",
|
||||
both, "")
|
||||
s.products("six-ladders", "Six ladders and four add-ons", "six", &unfamiliar,
|
||||
"Six ladders of two to four tiers and four add-ons, names unfamiliar. Exactly two lines on the page: one under Plans, one under the add-ons heading.",
|
||||
both, both)
|
||||
s.products("addons-only", "Add-ons only", "addons", &unfamiliar,
|
||||
"No ladder, four add-ons, names unfamiliar. The one line sits under the add-ons heading.",
|
||||
"", both)
|
||||
s.products("recognised", "Names recognised", "six", &recognised,
|
||||
"The same six ladders and add-ons, with the name and the descriptor equal to the deployment name ("+s.brand+"). No line anywhere.",
|
||||
"", "")
|
||||
s.products("name-only", "Descriptor unknown", "one", &nameOnly,
|
||||
"The name is unfamiliar and no payment has been made yet, so the descriptor is unknown. Only the name sentence shows.",
|
||||
"Billed by "+otherName+".", "")
|
||||
s.products("descriptor-only", "Name recognised, descriptor not", "one", &descriptorOnly,
|
||||
"The name equals the deployment name; the descriptor does not. Only the statement sentence shows.",
|
||||
"Card statement: "+otherDescriptor+".", "")
|
||||
s.products("another-key", "Record from another key", "one", &anotherKey,
|
||||
"The stored record was read under a different API key, so it counts as no record until the boot read replaces it. No line.",
|
||||
"", "")
|
||||
s.products("no-record", "No record", "one", nil,
|
||||
"No read has finished (Temporal down at boot, or the first boot still reading). No line; pages behave as before the change.",
|
||||
"", "")
|
||||
|
||||
s.catalog("six")
|
||||
s.dashboard("return-success", "Return after paying", "/?checkout=success", &unfamiliar,
|
||||
"Back from Stripe after paying, names unfamiliar. The success alert repeats both sentences.",
|
||||
both, true)
|
||||
s.dashboard("return-success-recognised", "Return after paying, names recognised", "/?checkout=success", &recognised,
|
||||
"Back from Stripe after paying, names recognised. The alert carries its usual sentence only.",
|
||||
"", true)
|
||||
s.dashboard("return-cancel", "Return after cancelling", "/?checkout=cancel", &unfamiliar,
|
||||
"Back from Stripe without paying, names unfamiliar. Nothing is added.",
|
||||
"", false)
|
||||
|
||||
s.record(&unfamiliar)
|
||||
s.provider("provider-fallback-charge", "Fallback read after a payment",
|
||||
"The key cannot read the account. The name came from an invoice preview, the descriptor from the latest charge, and the icon names the permission it needs.",
|
||||
"As printed on the latest payment.", "Needs the Accounts Read permission on the API key.", "Shown to members", both)
|
||||
fallbackFirst := nameOnly
|
||||
s.record(&fallbackFirst)
|
||||
s.provider("provider-fallback-first-payment", "Fallback read before the first payment",
|
||||
"The key cannot read the account, and no payment exists yet.",
|
||||
"Read after the first payment.", "Billed by "+otherName+".")
|
||||
noPrice := record(fingerprint, false, notYet(stripedb.SourceInvoicePreview), notYet(stripedb.SourceLatestCharge), notPermitted(stripedb.PermissionAccounts))
|
||||
s.record(&noPrice)
|
||||
s.provider("provider-fallback-no-price", "Fallback read with no recurring price",
|
||||
"The key cannot read the account, and the account holds no recurring price to preview an invoice with.",
|
||||
"Read after the account has a recurring price.", "Read after the first payment.")
|
||||
refused := record(fingerprint, false, notPermitted(stripedb.PermissionInvoices), notPermitted(stripedb.PermissionCharges), notPermitted(stripedb.PermissionAccounts))
|
||||
s.record(&refused)
|
||||
s.provider("provider-fallback-refused", "Fallback reads refused",
|
||||
"The key holds none of Accounts Read, Invoices or Charges. Each row names the permission it needs, and members are shown nothing.",
|
||||
"Needs the Invoices permission on the API key.", "Needs the Charges permission on the API key.", "Shown to members Nothing.")
|
||||
accountLegal := record(fingerprint, true,
|
||||
stripedb.PublicDetail{State: stripedb.DetailRead, Value: otherName, Source: stripedb.SourceAccount, LegalName: true},
|
||||
stripedb.PublicDetail{State: stripedb.DetailRead, Value: strings.ToUpper(otherName), Source: stripedb.SourceAccount, LegalName: true},
|
||||
stripedb.PublicDetail{State: stripedb.DetailUnset, Source: stripedb.SourceAccount})
|
||||
s.record(&accountLegal)
|
||||
s.provider("provider-account-legal", "Account read, names left at the legal name",
|
||||
"The key can read the account. Stripe copied the legal name into the public name and the descriptor, and no icon is uploaded.",
|
||||
"Same as the legal name.", "Not set", "Shown to members Billed by "+otherName+".")
|
||||
accountChosen := record(fingerprint, true,
|
||||
read(s.brand, stripedb.SourceAccount),
|
||||
read(strings.ToUpper(s.brand), stripedb.SourceAccount),
|
||||
stripedb.PublicDetail{State: stripedb.DetailRead, Source: stripedb.SourceAccount})
|
||||
s.record(&accountChosen)
|
||||
s.provider("provider-account-chosen", "Account read, names chosen",
|
||||
"The key can read the account, the operator set the public name and the descriptor to the deployment name, and an icon is uploaded. No note after either value, and members are shown nothing.",
|
||||
"Set", "Nothing; both match the deployment name.")
|
||||
s.record(&anotherKey)
|
||||
s.provider("provider-another-key", "Record from another key",
|
||||
"The stored record was read under a different API key.",
|
||||
"API key changed since the last read.")
|
||||
|
||||
// Last, because the positions they confer cannot be taken back short of
|
||||
// the final reset. The grant first: the subscription supersedes it.
|
||||
s.grantPlus()
|
||||
s.products("grant-held", "A rung held by a grant", "one", &unfamiliar,
|
||||
"The member holds the middle rung by a complimentary grant. Only a subscription can be switched or cancelled, so the rung above goes to Checkout and the rung below has no control. The Checkout button costs money, so the line shows and describes it.",
|
||||
both, "")
|
||||
s.subscribePlus()
|
||||
s.products("switch-only", "Switch-only region", "one", &unfamiliar,
|
||||
"The member pays for the middle rung, so the rung above offers a switch and the one below a downgrade switch or, when it is not priced for purchase, a disabled control. A switch charges the card on file, so the line shows under Plans and describes each enabled switch; a disabled control is not described by it.",
|
||||
both, "")
|
||||
|
||||
if os.Getenv("MERCHANT_SHEET_KEEP") == "1" {
|
||||
s.catalog("six")
|
||||
s.record(&unfamiliar)
|
||||
t.Log("MERCHANT_SHEET_KEEP=1: the database keeps six ladders, four add-ons and an unfamiliar record; view the Products page as carlos, then run test/reset-app-db.sh")
|
||||
}
|
||||
s.write()
|
||||
}
|
||||
|
||||
func record(fingerprint string, accountRead bool, name, descriptor, icon stripedb.PublicDetail) stripedb.PublicDetailsRecord {
|
||||
now := time.Now().UTC()
|
||||
return stripedb.PublicDetailsRecord{KeyFingerprint: fingerprint, ReadAt: &now, AccountRead: accountRead, Name: name, Descriptor: descriptor, Icon: icon}
|
||||
}
|
||||
|
||||
func read(value, source string) stripedb.PublicDetail {
|
||||
return stripedb.PublicDetail{State: stripedb.DetailRead, Value: value, Source: source}
|
||||
}
|
||||
|
||||
func notYet(source string) stripedb.PublicDetail {
|
||||
return stripedb.PublicDetail{State: stripedb.DetailNotYet, Source: source}
|
||||
}
|
||||
|
||||
func notPermitted(permission string) stripedb.PublicDetail {
|
||||
return stripedb.PublicDetail{State: stripedb.DetailNotPermitted, Permission: permission}
|
||||
}
|
||||
|
||||
// resetAppDB puts the application database at the demo snapshot, as
|
||||
// `make screens` does first. It ends every browser session.
|
||||
func resetAppDB(t *testing.T) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("./reset-app-db.sh")
|
||||
cmd.Dir = filepath.Join("..", "..")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("reset the application database: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sheet) exec(query string, args ...any) {
|
||||
s.t.Helper()
|
||||
if _, err := s.db.ExecContext(s.ctx, query, args...); err != nil {
|
||||
s.t.Fatalf("%v\n%s", err, query)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sheet) id(query string, args ...any) string {
|
||||
s.t.Helper()
|
||||
var id string
|
||||
if err := s.db.QueryRowContext(s.ctx, query, args...).Scan(&id); err != nil {
|
||||
s.t.Fatalf("%v\n%s", err, query)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// record stores rec as the public-details record, or removes the record
|
||||
// when rec is nil. Pages read it on every render.
|
||||
func (s *sheet) record(rec *stripedb.PublicDetailsRecord) {
|
||||
s.t.Helper()
|
||||
if rec == nil {
|
||||
s.exec(`DELETE FROM core.instance_settings WHERE key = 'stripe.public_details'`)
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
s.t.Fatalf("marshal record: %v", err)
|
||||
}
|
||||
s.exec(`INSERT INTO core.instance_settings (key, value) VALUES ('stripe.public_details', $1::jsonb)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, string(raw))
|
||||
}
|
||||
|
||||
// readAgain presses Read again and returns the fingerprint of the record
|
||||
// the read wrote.
|
||||
func (s *sheet) readAgain() string {
|
||||
s.t.Helper()
|
||||
s.op.MustEval(`() => document.querySelector('button[hx-target="#stripe-public-details"]').click()`)
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var raw []byte
|
||||
err := s.db.QueryRowContext(s.ctx, `SELECT value FROM core.instance_settings WHERE key = 'stripe.public_details'`).Scan(&raw)
|
||||
if err == nil {
|
||||
rec, ok := stripedb.DecodePublicDetailsRecord(raw, true)
|
||||
if ok && rec.KeyFingerprint != "" {
|
||||
return rec.KeyFingerprint
|
||||
}
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
s.t.Skip("Read again wrote no record within 30 seconds (Temporal or the Stripe sandbox unreachable), so there is no fingerprint to seed records under")
|
||||
return ""
|
||||
}
|
||||
|
||||
// seedCatalog prices and maps the demo ladder's paid rungs, renames the
|
||||
// ladder and its rungs for the sheet, and adds five ladders and four
|
||||
// add-ons, every price mapped as synced in test mode. Nothing here reaches
|
||||
// Stripe: the mappings name ids no Stripe account holds, and the capture
|
||||
// never presses a checkout button.
|
||||
func (s *sheet) seedCatalog() {
|
||||
s.t.Helper()
|
||||
s.demoLadder = s.id(`UPDATE core.plan_ladders SET name = 'Membership' WHERE key = 'demo_ladder' RETURNING plan_ladder_id`)
|
||||
for key, rename := range map[string][2]string{
|
||||
"demo_plan": {"Basic", "Everything a member needs to start."},
|
||||
"demo_plan_2": {"Plus", "More room and more sites."},
|
||||
"demo_plan_3": {"Pro", "The most room, with priority help."},
|
||||
} {
|
||||
s.exec(`UPDATE core.products SET name = $2, description = $3 WHERE key = $1`, key, rename[0], rename[1])
|
||||
}
|
||||
s.price(s.id(`SELECT product_id FROM core.products WHERE key = 'demo_plan_2'`), "demo_plan_2", 1200)
|
||||
s.price(s.id(`SELECT product_id FROM core.products WHERE key = 'demo_plan_3'`), "demo_plan_3", 3000)
|
||||
|
||||
for i, l := range extraLadders {
|
||||
ladderID := s.id(`INSERT INTO core.plan_ladders (key, name, sort_order) VALUES ($1, $2, $3) RETURNING plan_ladder_id`, l.key, l.name, i+1)
|
||||
for rank, p := range l.tiers {
|
||||
productID := s.product(p, "")
|
||||
s.exec(`INSERT INTO core.plan_ladder_tiers (plan_ladder_id, product_id, rank) VALUES ($1, $2, $3)`, ladderID, productID, rank)
|
||||
}
|
||||
}
|
||||
for _, p := range addons {
|
||||
s.product(p, "addon")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sheet) product(p product, category string) string {
|
||||
s.t.Helper()
|
||||
productID := s.id(`INSERT INTO core.products (key, name, description, display_category, is_active, is_public, lifecycle_status)
|
||||
VALUES ($1, $2, NULLIF($3, ''), NULLIF($4, ''), TRUE, FALSE, 'published') RETURNING product_id`, p.key, p.name, p.about, category)
|
||||
if p.cents > 0 {
|
||||
s.price(productID, p.key, p.cents)
|
||||
}
|
||||
return productID
|
||||
}
|
||||
|
||||
func (s *sheet) price(productID, key string, cents int) {
|
||||
s.t.Helper()
|
||||
priceID := s.id(`INSERT INTO core.prices (product_id, key, currency, unit_amount, recurring_interval, is_default)
|
||||
VALUES ($1, 'sheet_monthly', COALESCE((SELECT currency FROM core.prices LIMIT 1), 'usd'), $2, 'month', TRUE) RETURNING price_id`, productID, cents)
|
||||
s.exec(`INSERT INTO stripe.price_mappings (price_id, stripe_price_id, sync_status, livemode) VALUES ($1, $2, 'synced', FALSE)`,
|
||||
priceID, "price_sheet_"+key)
|
||||
}
|
||||
|
||||
// catalog makes one catalog shape the member sees: "one" (the demo ladder
|
||||
// alone), "six" (six ladders and four add-ons) or "addons" (add-ons alone;
|
||||
// the demo ladder's tiers are detached, since every demo member holds its
|
||||
// free rung and a held rung always renders).
|
||||
func (s *sheet) catalog(shape string) {
|
||||
s.t.Helper()
|
||||
var all, shown []string
|
||||
all = append(all, demoTiers...)
|
||||
var extra, addonKeys []string
|
||||
for _, l := range extraLadders {
|
||||
for _, p := range l.tiers {
|
||||
extra = append(extra, p.key)
|
||||
}
|
||||
}
|
||||
for _, p := range addons {
|
||||
addonKeys = append(addonKeys, p.key)
|
||||
}
|
||||
all = append(append(all, extra...), addonKeys...)
|
||||
switch shape {
|
||||
case "one":
|
||||
shown = demoTiers
|
||||
case "six":
|
||||
shown = all
|
||||
case "addons":
|
||||
shown = addonKeys
|
||||
default:
|
||||
s.t.Fatalf("unknown catalog shape %q", shape)
|
||||
}
|
||||
s.exec(`UPDATE core.products SET is_public = (key = ANY($2)) WHERE key = ANY($1)`, all, shown)
|
||||
if shape == "addons" {
|
||||
s.exec(`DELETE FROM core.plan_ladder_tiers WHERE plan_ladder_id = $1`, s.demoLadder)
|
||||
return
|
||||
}
|
||||
for rank, key := range demoTiers {
|
||||
s.exec(`INSERT INTO core.plan_ladder_tiers (plan_ladder_id, product_id, rank)
|
||||
SELECT $1, product_id, $3 FROM core.products WHERE key = $2 ON CONFLICT DO NOTHING`, s.demoLadder, key, rank)
|
||||
}
|
||||
}
|
||||
|
||||
// dianaOrg returns alice's person id and diana's personal organization,
|
||||
// the operator and the member the rung scenarios use.
|
||||
func (s *sheet) dianaOrg() (alice, org string) {
|
||||
s.t.Helper()
|
||||
persons, err := identity.New(s.db).ListPersons(s.ctx)
|
||||
if err != nil {
|
||||
s.t.Fatalf("list persons: %v", err)
|
||||
}
|
||||
var diana string
|
||||
for _, p := range persons {
|
||||
switch p.PrimaryEmail {
|
||||
case "alice@example.com":
|
||||
alice = p.PersonID
|
||||
case "diana@example.com":
|
||||
diana = p.PersonID
|
||||
}
|
||||
}
|
||||
if alice == "" || diana == "" {
|
||||
s.t.Fatalf("the demo snapshot lacks alice or diana")
|
||||
}
|
||||
orgs, err := organization.New(s.db).GetOrganizationsByOwner(s.ctx, diana)
|
||||
if err != nil {
|
||||
s.t.Fatalf("list diana's organizations: %v", err)
|
||||
}
|
||||
for _, o := range orgs {
|
||||
if o.OrgType == "personal" {
|
||||
org = o.OrgID
|
||||
}
|
||||
}
|
||||
if org == "" {
|
||||
s.t.Fatalf("diana has no personal organization")
|
||||
}
|
||||
return alice, org
|
||||
}
|
||||
|
||||
// grantPlus confers the demo ladder's middle rung on diana's personal
|
||||
// organization as alice, the way the operator's grant form does. A rung
|
||||
// held by a grant moves up through Checkout and has no move down.
|
||||
func (s *sheet) grantPlus() {
|
||||
s.t.Helper()
|
||||
alice, org := s.dianaOrg()
|
||||
_, err := entitlements.ConferGrant(s.ctx, s.db, entitlements.ConferGrantInput{
|
||||
ProductID: s.id(`SELECT product_id FROM core.products WHERE key = 'demo_plan_2'`),
|
||||
OrgID: org,
|
||||
GrantedByPersonID: alice,
|
||||
GrantReason: "complimentary",
|
||||
Description: "Merchant disclosure sheet: a paid rung held by a grant.",
|
||||
Quantity: 1,
|
||||
ActorType: "operator",
|
||||
ActorID: uuid.NullUUID{UUID: uuid.MustParse(alice), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
s.t.Fatalf("confer the middle rung on diana: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// subscribePlus gives diana's personal organization the middle rung through
|
||||
// a projected subscription, the rows the reconcile writes after a checkout,
|
||||
// so every other priced rung is a switch. Nothing reaches Stripe: the
|
||||
// capture never opens a preview or presses a move.
|
||||
func (s *sheet) subscribePlus() {
|
||||
s.t.Helper()
|
||||
_, org := s.dianaOrg()
|
||||
product := s.id(`SELECT product_id FROM core.products WHERE key = 'demo_plan_2'`)
|
||||
price := s.id(`SELECT price_id FROM core.prices WHERE product_id = $1 AND is_active ORDER BY created_at LIMIT 1`, product)
|
||||
tx, err := entitlements.BeginMaterializing(s.ctx, s.db, nil)
|
||||
if err != nil {
|
||||
s.t.Fatalf("begin: %v", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
bq := billing.New(tx)
|
||||
account, err := bq.CreateBillingAccount(s.ctx, billing.CreateBillingAccountParams{
|
||||
OrgID: org, Name: "Merchant disclosure sheet", Status: "active", Metadata: json.RawMessage("{}"),
|
||||
})
|
||||
if err != nil {
|
||||
s.t.Fatalf("create billing account: %v", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
sub, err := bq.CreateSubscription(s.ctx, billing.CreateSubscriptionParams{
|
||||
BillingAccountID: account.BillingAccountID, Status: "active",
|
||||
CurrentPeriodStart: sql.NullTime{Time: now.AddDate(0, 0, -10), Valid: true},
|
||||
CurrentPeriodEnd: sql.NullTime{Time: now.AddDate(0, 0, 20), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
s.t.Fatalf("create subscription: %v", err)
|
||||
}
|
||||
if _, err := bq.CreateSubscriptionItem(s.ctx, billing.CreateSubscriptionItemParams{
|
||||
SubscriptionID: sub.SubscriptionID, ProductID: product, PriceID: price, Quantity: 1,
|
||||
}); err != nil {
|
||||
s.t.Fatalf("create subscription item: %v", err)
|
||||
}
|
||||
pool := s.id(`SELECT pool_id FROM core.resource_pools WHERE org_id = $1 ORDER BY created_at LIMIT 1`, org)
|
||||
if _, _, err := entitlements.New(tx).Confer(s.ctx, entitlements.ConferParams{
|
||||
PoolID: pool, ProductID: product, Quantity: 1, ActorType: "system",
|
||||
SubscriptionID: uuid.NullUUID{UUID: uuid.MustParse(sub.SubscriptionID), Valid: true},
|
||||
}); err != nil {
|
||||
s.t.Fatalf("confer the middle rung by subscription: %v", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
s.t.Fatalf("commit: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// visit loads url at w and waits for the page to settle, the way the
|
||||
// screens utility does.
|
||||
func visit(page *rod.Page, w width, url string) {
|
||||
page.MustSetViewport(w.W, w.H, w.Scale, w.Mobile)
|
||||
wait := page.Timeout(30 * time.Second).MustWaitNavigation()
|
||||
page.Timeout(30 * time.Second).MustNavigate(url)
|
||||
wait()
|
||||
_ = rod.Try(func() { page.Timeout(10 * time.Second).MustWait(`() => !document.querySelector('.htmx-request')`) })
|
||||
_ = rod.Try(func() { page.Timeout(5 * time.Second).MustWaitDOMStable() })
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
}
|
||||
|
||||
// capture visits path at every width, runs check on the page, and saves a
|
||||
// full-page PNG.
|
||||
func (s *sheet) capture(page *rod.Page, section, name, title, about, path string, check func(*rod.Page) error) {
|
||||
s.t.Helper()
|
||||
files := make(map[string]string, len(widths))
|
||||
for _, w := range widths {
|
||||
visit(page, w, s.base+path)
|
||||
if err := check(page); err != nil {
|
||||
s.t.Errorf("%s at %s: %v", name, w.Name, err)
|
||||
}
|
||||
file := name + "-" + w.Name + ".png"
|
||||
if err := os.WriteFile(filepath.Join(sheetDir, file), page.MustScreenshotFullPage(), 0o644); err != nil {
|
||||
s.t.Fatalf("write %s: %v", file, err)
|
||||
}
|
||||
files[w.Name] = file
|
||||
}
|
||||
s.shots = append(s.shots, shot{section: section, name: name, title: title, about: about, files: files})
|
||||
}
|
||||
|
||||
func text(page *rod.Page, js string, args ...any) string {
|
||||
return page.MustEval(js, args...).String()
|
||||
}
|
||||
|
||||
// products captures the Products page for one catalog shape and record,
|
||||
// checking each section's line and the controls that reference it.
|
||||
func (s *sheet) products(name, title, shape string, rec *stripedb.PublicDetailsRecord, about, wantPlans, wantAddons string) {
|
||||
s.t.Helper()
|
||||
s.catalog(shape)
|
||||
s.record(rec)
|
||||
s.capture(s.member, "Products page", name, title, about, "/products", func(page *rod.Page) error {
|
||||
for id, want := range map[string]string{"plans-billing-facts": wantPlans, "addons-billing-facts": wantAddons} {
|
||||
got := text(page, `id => { const e = document.getElementById(id); return e ? e.innerText.replace(/\s+/g, ' ').trim() : ''; }`, id)
|
||||
refs := page.MustEval(`id => document.querySelectorAll('[aria-describedby~="' + id + '"]').length`, id).Int()
|
||||
switch {
|
||||
case got != want:
|
||||
return fmt.Errorf("#%s reads %q, want %q", id, got, want)
|
||||
case want != "" && refs == 0:
|
||||
return fmt.Errorf("#%s is shown and no control references it", id)
|
||||
case want == "" && refs > 0:
|
||||
return fmt.Errorf("%d controls reference #%s, which is not shown", refs, id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// dashboard captures a return from Stripe and checks whether its alert
|
||||
// carries the sentences.
|
||||
func (s *sheet) dashboard(name, title, path string, rec *stripedb.PublicDetailsRecord, about, want string, success bool) {
|
||||
s.t.Helper()
|
||||
s.record(rec)
|
||||
s.capture(s.member, "Return from Stripe", name, title, about, path, func(page *rod.Page) error {
|
||||
alerts := text(page, `() => [...document.querySelectorAll('.alert')].map(a => a.innerText).join(' ').replace(/\s+/g, ' ').trim()`)
|
||||
if success && alerts == "" {
|
||||
return fmt.Errorf("no alert on a success return")
|
||||
}
|
||||
if want != "" && !strings.Contains(alerts, want) {
|
||||
return fmt.Errorf("the alert reads %q, want it to carry %q", alerts, want)
|
||||
}
|
||||
if want == "" && (strings.Contains(alerts, "Billed by") || strings.Contains(alerts, "Card statement")) {
|
||||
return fmt.Errorf("the alert carries a sentence it should not: %q", alerts)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// provider captures the Stripe provider page and checks the Public details
|
||||
// section carries every wanted string.
|
||||
func (s *sheet) provider(name, title, about string, wants ...string) {
|
||||
s.t.Helper()
|
||||
s.capture(s.op, "Provider page", name, title, about, "/operator/integrations/stripe", func(page *rod.Page) error {
|
||||
got := text(page, `() => { const e = document.getElementById('stripe-public-details'); return e ? e.innerText.replace(/\s+/g, ' ').trim() : ''; }`)
|
||||
for _, want := range wants {
|
||||
if !strings.Contains(got, want) {
|
||||
return fmt.Errorf("the section reads %q, want it to carry %q", got, want)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// write renders sheet.html: every scenario in capture order, grouped by
|
||||
// surface, then any PNG captured by hand into the folder.
|
||||
func (s *sheet) write() {
|
||||
s.t.Helper()
|
||||
var b strings.Builder
|
||||
b.WriteString(`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Merchant disclosure scenarios</title><style>
|
||||
body{font-family:system-ui,sans-serif;margin:24px;color:#1d2125;background:#fff}
|
||||
h1{font-size:22px}h2{font-size:18px;margin-top:40px;border-bottom:1px solid #ccc;padding-bottom:4px}
|
||||
h3{font-size:15px;margin:28px 0 4px}p{margin:0 0 10px;max-width:70em}
|
||||
.row{display:grid;grid-template-columns:1280fr 390fr;gap:16px;align-items:start}
|
||||
figure{margin:0}figcaption{font-size:12px;color:#555;margin-bottom:4px}
|
||||
img{width:100%;border:1px solid #ddd}
|
||||
</style></head><body>`)
|
||||
fmt.Fprintf(&b, "<h1>Merchant disclosure scenarios</h1><p>Deployment name %s. Captured %s. Desktop 1280 wide, mobile 390 wide.</p>",
|
||||
html.EscapeString(s.brand), time.Now().Format("Jan 2, 2006 3:04 PM"))
|
||||
section := ""
|
||||
for _, sh := range s.shots {
|
||||
if sh.section != section {
|
||||
section = sh.section
|
||||
fmt.Fprintf(&b, "<h2>%s</h2>", html.EscapeString(section))
|
||||
}
|
||||
fmt.Fprintf(&b, `<h3 id="%s">%s</h3><p>%s</p><div class="row">`, sh.name, html.EscapeString(sh.title), html.EscapeString(sh.about))
|
||||
for _, w := range widths {
|
||||
fmt.Fprintf(&b, `<figure><figcaption>%s</figcaption><img src="%s" alt="%s at %s width"></figure>`,
|
||||
w.Name, sh.files[w.Name], html.EscapeString(sh.title), w.Name)
|
||||
}
|
||||
b.WriteString("</div>")
|
||||
}
|
||||
var byHand []string
|
||||
for _, pattern := range []string{"real-*.png", "billing-off-*.png"} {
|
||||
matches, _ := filepath.Glob(filepath.Join(sheetDir, pattern))
|
||||
for _, m := range matches {
|
||||
byHand = append(byHand, filepath.Base(m))
|
||||
}
|
||||
}
|
||||
sort.Strings(byHand)
|
||||
if len(byHand) > 0 {
|
||||
b.WriteString("<h2>Captured by hand</h2><p>Real reads against the sandbox and the billing-off pages, captured in the browser rather than by this run.</p>")
|
||||
for _, f := range byHand {
|
||||
fmt.Fprintf(&b, `<h3>%s</h3><figure><img src="%s" alt="%s" style="max-width:%s"></figure>`,
|
||||
html.EscapeString(f), f, html.EscapeString(f), map[bool]string{true: "390px", false: "1280px"}[strings.Contains(f, "-390")])
|
||||
}
|
||||
}
|
||||
b.WriteString("</body></html>\n")
|
||||
out := filepath.Join(sheetDir, "sheet.html")
|
||||
if err := os.WriteFile(out, []byte(b.String()), 0o644); err != nil {
|
||||
s.t.Fatalf("write %s: %v", out, err)
|
||||
}
|
||||
abs, _ := filepath.Abs(out)
|
||||
s.t.Logf("sheet: file://%s", abs)
|
||||
}
|
||||
@@ -8,7 +8,8 @@
|
||||
// (stripe-environment-stamp D3): a Stripe id resolves only in the
|
||||
// environment that made it, so the console reads its mapped product and
|
||||
// price ids back under the key in force and states the outcome in one
|
||||
// line. The control starts that read-back again without a restart.
|
||||
// line. The control in the section's header starts that read-back again
|
||||
// without a restart.
|
||||
// - "valid" → press the control; the section re-renders and its line
|
||||
// is one of the four states the design names
|
||||
// - "absent" → a stack whose Stripe is unconfigured renders no section,
|
||||
@@ -39,7 +40,7 @@ func TestStripeEnvironmentCheckWalkthrough(t *testing.T) {
|
||||
t.Fatalf("the Environment check section carries none of the four resting lines: %s", body)
|
||||
}
|
||||
|
||||
// The section already carries its control and one of the four resting
|
||||
// The page already carries the control and one of the four resting
|
||||
// lines before anything is pressed, so a poll on either passes on its
|
||||
// first turn whether the response arrived or not. The toast is the one
|
||||
// thing only the response produces: the handler fires one on every
|
||||
@@ -63,7 +64,10 @@ func TestStripeEnvironmentCheckWalkthrough(t *testing.T) {
|
||||
}
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
page.MustElement(`#stripe-environment-check button`).MustClick()
|
||||
// The control sits in the section header and targets the body, which
|
||||
// is what it re-renders.
|
||||
control := `button[hx-target="#stripe-environment-check"]`
|
||||
page.Timeout(10 * time.Second).MustElement(control).MustClick()
|
||||
var succeeded, failed string
|
||||
deadline := time.Now().Add(20 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
@@ -90,8 +94,8 @@ func TestStripeEnvironmentCheckWalkthrough(t *testing.T) {
|
||||
body = html
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body, "Check now") {
|
||||
t.Fatalf("phase 1: the control is gone after the check was started: %s", body)
|
||||
if has, _, err := page.Has(control); err != nil || !has {
|
||||
t.Fatal("phase 1: the control is gone after the check was started")
|
||||
}
|
||||
if !environmentCheckLine(body) {
|
||||
t.Fatalf("phase 1: the re-rendered section carries no resting line: %s", body)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
// walkthrough: stripe-public-details
|
||||
// covers: POST /partials/operator/integrations/stripe/public-details
|
||||
//
|
||||
// The Stripe provider page's Public details section (merchant-disclosure
|
||||
// D6): the business name, statement descriptor and icon members meet in
|
||||
// Stripe's pages, read with whatever the API key allows, and the control
|
||||
// that reads them again.
|
||||
// - "valid" → press Read again; a toast answers and the section
|
||||
// re-renders with one of its three resting lines
|
||||
// - "absent" → a stack whose Stripe is unconfigured renders no section,
|
||||
// so the walkthrough skips
|
||||
package operatorwalkthroughs_test
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStripePublicDetailsWalkthrough(t *testing.T) {
|
||||
base := baseURL(t)
|
||||
browser := newBrowser(t)
|
||||
page := browser.MustPage()
|
||||
|
||||
loginAsOperator(t, page, base, "/operator/integrations/stripe")
|
||||
|
||||
body := page.Timeout(10 * time.Second).MustElement(`#operator-body`).MustHTML()
|
||||
if !strings.Contains(body, `id="stripe-public-details"`) {
|
||||
t.Skip("Stripe is unconfigured on this stack, so the provider page carries no Public details section")
|
||||
}
|
||||
if !strings.Contains(body, "Read again") {
|
||||
t.Fatalf("the Public details section renders without its control: %s", body)
|
||||
}
|
||||
if !publicDetailsLine(body) {
|
||||
t.Fatalf("the Public details section carries none of its resting lines: %s", body)
|
||||
}
|
||||
|
||||
// Only a shown toast is an answer: both toasts sit hidden in the page
|
||||
// from the first paint with a default body.
|
||||
t.Logf("phase 1: press Read again → expect the outcome in a toast and the section re-rendered in place")
|
||||
toastText := func(toast string) string {
|
||||
shown, _, err := page.Has("#" + toast + ".show")
|
||||
if err != nil || !shown {
|
||||
return ""
|
||||
}
|
||||
el, err := page.Element("#" + toast + "Body")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
text, err := el.Text()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
// The control sits in the section header and targets the body, which
|
||||
// is what it re-renders.
|
||||
control := `button[hx-target="#stripe-public-details"]`
|
||||
page.Timeout(10 * time.Second).MustElement(control).MustClick()
|
||||
var succeeded, failed string
|
||||
deadline := time.Now().Add(20 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
succeeded, failed = toastText("successToast"), toastText("errorToast")
|
||||
if succeeded != "" || failed != "" {
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
if failed != "" {
|
||||
t.Fatalf("phase 1: the control answered with an error toast: %s", failed)
|
||||
}
|
||||
switch succeeded {
|
||||
case "Public details read.", "Read started.", "Read running.":
|
||||
case "":
|
||||
t.Fatal("phase 1: the control fired no toast, so nothing answered the press")
|
||||
default:
|
||||
t.Fatalf("phase 1: the toast says %q, which is none of the outcomes the control has", succeeded)
|
||||
}
|
||||
|
||||
if el, err := page.Element(`#stripe-public-details`); err == nil {
|
||||
if html, err := el.HTML(); err == nil {
|
||||
body = html
|
||||
}
|
||||
}
|
||||
if has, _, err := page.Has(control); err != nil || !has {
|
||||
t.Fatal("phase 1: the control is gone after the read was started")
|
||||
}
|
||||
if !publicDetailsLine(body) {
|
||||
t.Fatalf("phase 1: the re-rendered section lost its resting line: %s", body)
|
||||
}
|
||||
if succeeded == "Public details read." && !strings.Contains(body, "Public business name") {
|
||||
t.Fatalf("phase 1: a finished read left no rows: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// readAtLine matches the resting line of a finished read, "Read Sep 25,
|
||||
// 2026 3:04 PM.", and not the control's own label.
|
||||
var readAtLine = regexp.MustCompile(`Read [A-Z][a-z]{2} \d{1,2}, \d{4} `)
|
||||
|
||||
// publicDetailsLine reports whether the markup carries one of the section's
|
||||
// three resting lines.
|
||||
func publicDetailsLine(html string) bool {
|
||||
return strings.Contains(html, "Not read yet.") ||
|
||||
strings.Contains(html, "API key changed since the last read.") ||
|
||||
readAtLine.MatchString(html)
|
||||
}
|
||||
Reference in New Issue
Block a user