Files
cgalo5758 27fb02fbfa 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/.
2026-09-28 18:39:41 -05:00

124 lines
4.5 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"fmt"
"html/template"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
)
// Server-side composition (spec page-anatomy "A page arrives complete";
// docs/design-system.md §6). A page region that also exists as an HTMX
// partial, so a mutation can swap it back in, is rendered into the page by
// the server on first paint through an in-process request to the partial's
// own route. The browser never fetches first-paint content after load: one
// response, no spinner, and the partial stays the single source of the
// region's markup.
// Include renders a GET route in-process, with the caller's request context
// (session, request ID) and headers, and returns its HTML fragment. Core
// builds one against its router and hands it to integrations through Deps
// so their card bodies compose the same way.
type Include func(r *http.Request, path string) (template.HTML, error)
// NewInclude returns an Include that dispatches through router. The router
// is the mux the partial routes are registered on; the middleware stack sits
// outside it, so an include never re-authenticates: the session already
// rides the context it is given.
func NewInclude(router http.Handler) Include {
return func(r *http.Request, path string) (template.HTML, error) {
u, err := url.Parse(path)
if err != nil {
return "", fmt.Errorf("include %s: %w", path, err)
}
req := r.Clone(r.Context())
req.Method = http.MethodGet
req.URL = u
req.RequestURI = path
req.Body = http.NoBody
req.ContentLength = 0
req.Header.Set("HX-Request", "true")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
return "", fmt.Errorf("include %s: status %d", path, rec.Code)
}
return template.HTML(rec.Body.String()), nil
}
}
// IncludeOr renders the fragment, or an inline notice naming what could not
// be loaded, so a failing region never fails the page and never renders
// blank. A nil Include (a test set without a router) renders nothing.
func IncludeOr(inc Include, logger *slog.Logger, r *http.Request, path, what string) template.HTML {
if inc == nil {
return ""
}
out, err := inc(r, path)
if err != nil {
if logger != nil {
logger.Error("page region failed to compose", slog.String("path", path), slog.Any("error", err))
}
return template.HTML(`<div class="alert alert-danger" role="alert">Could not load ` + template.HTMLEscapeString(what) + `.</div>`)
}
return out
}
// DashboardCardView is a declared card with its body composed for this
// request. PartialPath stays the swap source for the card's RefreshEvent.
type DashboardCardView struct {
DashboardCard
Body template.HTML
}
// ProductsPageData is the /products page: the shell plus its three regions,
// composed from their partial routes.
type ProductsPageData struct {
Shell Shell
Entitlements template.HTML
Plans template.HTML
Addons template.HTML
}
// BillingPageData is the /billing page: the shell plus the invoices region.
type BillingPageData struct {
Shell Shell
Invoices template.HTML
}
// IndexPageData is the / dashboard page: the member's identity, the
// post-checkout banner state, pending domain claims, the composed
// workspaces region, and the integration dashboard cards. Named (rather
// than the handler's former anonymous struct) so the page-anatomy header
// and section methods in anatomy.go can hang off it.
type IndexPageData struct {
Name string
Username string
Email string
KeycloakAccountURL string
IsOperator bool
HasMultipleWorkspaces bool
CheckoutStatus string
DashboardCards []DashboardCardView
Workspaces template.HTML
PendingDomainClaims []PendingDomainClaim
Shell Shell
// HasEntitlement reports whether the person's organization holds any
// entitlement (a numeric or granted-boolean entitlement, or a rung on
// a plan ladder) — computed alongside DashboardCards in server.go so
// IsEmpty (server.go) can decide the dashboard's one empty state
// (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
}