Let a member pay a plan's unpaid invoice on Stripe's page

A plan whose payment is due shows Pay invoice as its card's first
control, and an open invoice's detail shows it too. Each opens a console
route in a new tab: GET /billing/plans/{ladderID}/unpaid-invoice and
GET /billing/invoices/{invoiceID}/hosted. The route checks that the plan
or invoice belongs to the member's organization, the way the invoice
detail does and through the same gate function, reads the invoice from
Stripe when the member opens it, and answers 303 to its hosted invoice
page. The URL is a bearer link: it is never stored, logged or written
into a body, the redirect has no body, only an absolute https URL is
followed, and every answer carries Cache-Control: no-store.

The by-plan route answers /billing without a Stripe call for a plan
that is not suspended, a mapping the key cannot reach or no Stripe
querier, and for a latest invoice that is already paid; the by-invoice
route redirects a paid invoice to its receipt. A failed Stripe read logs
"member hosted invoice: stripe read failed" and answers /billing. Both
routes answer a refusal with the styled error page, reusing "The page
you requested does not exist." and "An unexpected error occurred. Try
again.", which the Copy table now lists as reused; the invoice detail,
a partial, keeps its plain answers. Pay invoice counts as a paid control
for the Products page's billing line.

Tests: TestHostedInvoiceRoutes (18 rows, another organization's plan and
invoice among them), TestInvoiceDetailOffersPayInvoice,
TestPayInvoiceReferencesTheBillingLine, new rows in
TestPlansOfferPaidMoveGate. The test log capture now carries With
attributes and keeps a repeated key's values, so the check that no line
carries the URL cannot be bypassed. Goldens: seven payment-due
member-plans pages gain Pay invoice; routes.golden and
routes-not-ready.golden gain the two routes (anonymous: 302 to /login);
route_pages.golden gains them as styled 404s.

Not caught by a break of their own: the by-plan gate's pool scoping and
its billing-account check guard together, and the other-organization
row catches only their conjunction (no single edit removes the scoping,
and the test kit cannot back one organization's position with another's
subscription); that the detail and the by-invoice route call one gate is
read in review; pageRefused's 500 branch has no row, since no fixture
can fail a read on the real database.

Rides along: the change's proposal and design name #255 to #258, the
issues filed for its out-of-scope items, and the #150 note; the design
gains S11, a sandbox check of an abandoned upgrade's invoice after its
pending update expires.

Suite 4603 pass, 15 skip, 0 fail. make screens: only the known
data-only differences (regenerated ids, a domain's age).

Breaks, all CAUGHT:
  by-invoice route without the ownership gate
  by-invoice route without the environment check
  by-invoice route redirecting only an open invoice
  by-invoice route accepting a URL that is not https
  by-plan route without its suspended check
  by-plan route redirecting a paid latest invoice
  by-plan route without its environment check
  by-plan read without the expand of latest_invoice
  by-plan route answering a missing position 303 to /billing
  by-invoice handler without its own Cache-Control
  by-plan handler without its own Cache-Control
  the hosted URL logged on success
  failed invoice read without its log line
  failed subscription read without its log line
  card without Pay invoice
  Pay invoice on a paused plan
  Pay invoice drawn without the LadderID check
  plansOfferPaidMove not counting Pay invoice
  Pay invoice without its aria-describedby
  PayPath set for an invoice in the other environment
  PayPath set for a paid invoice
  by-plan route not registered
  refusal of a Pay invoice route as bare text
  the hosted URL logged through Logger.With
This commit is contained in:
2026-10-24 03:12:37 -04:00
parent 996d6eee34
commit a7bea2fadf
24 changed files with 895 additions and 86 deletions
+1
View File
@@ -211,6 +211,7 @@ A member moves between the paid tiers of a plan ladder on the Products page, and
- **Cancellation.** Every paid plan offers **Cancel plan** on its card. Access continues until the end of the paid period (`cancel_at_period_end`), after which the organization moves to its type's default plan, when one is configured, or leaves the ladder.
- **Taking a scheduled move back.** While a downgrade or a cancellation is scheduled, the plan's card offers **Keep plan**, which releases the schedule or clears `cancel_at_period_end` at no charge. While a downgrade is scheduled, **Cancel plan** follows it and replaces the downgrade; while a cancellation is scheduled, **Keep plan** is the only control.
- **A plan whose payment is due.** A subscription Stripe holds as `past_due` or `unpaid` keeps its card on the Products page, shown as the current plan with its state, while its entitlements are suspended. No switch is offered in either direction and a posted one is refused before any Stripe call, because a switch would invoice while the failed invoice is still open. **Keep plan** and **Cancel plan** at the period end work as on an active plan. A posted immediate cancellation is refused, because Stripe's immediate cancellation stops automatic collection of the customer's finalized invoices, and a **Keep plan** over a downgrade that Stripe has already applied is refused, because there is nothing left to take back. The unpaid invoice stays due after a cancellation. A paused subscription shows its state and is refused every act.
- **Pay invoice.** The card of a plan whose payment is due, and the detail of an open invoice, link to Stripe's hosted invoice page. The console reads the page's URL from Stripe when the member opens the link and redirects there; it never stores or logs the URL, which is a bearer link, and it needs no key permission beyond the reads of subscriptions and invoices that the reconciles already make.
A restricted key needs permission for the calls these moves make, beyond those the console already uses: `POST /v1/subscription_schedules`, `POST /v1/subscription_schedules/{id}`, `POST /v1/subscription_schedules/{id}/release` and `POST /v1/invoices/{id}/void`. Stripe names the missing permission in the error a key without it receives.
@@ -40,6 +40,9 @@
<dd class="col-sm-9">{{ .PaidAt }}</dd>
{{ end }}
</dl>
{{ if .PayPath }}
<a href="{{ .PayPath }}" target="_blank" rel="noopener" hx-boost="false" class="btn btn-outline-primary btn-sm mb-3">Pay invoice</a>
{{ end }}
{{ template "sectionHeader" .LineItemsHeader }}
{{ if eq (len .LineItems) 0 }}
@@ -97,15 +97,18 @@
then Cancel plan; a scheduled cancellation, Keep alone.
Cancelling goes through the shared confirm modal, like the
held rung's and the free rung's controls below, which carry
the same body (grant-resumption D10, D16). */}}
{{ if eq .Relation "current" }}
the same body (grant-resumption D10, D16). A plan whose
payment is due offers Pay invoice first (design D8). */}}
{{ if eq .Relation "current" }}{{ $pay := .PaysInvoice }}{{ if $pay }}
<a href="/billing/plans/{{ .LadderID }}/unpaid-invoice" target="_blank" rel="noopener" hx-boost="false" class="btn btn-outline-primary btn-sm w-100 mt-auto"{{ if $.BillingFacts.Shown }} aria-describedby="{{ $.BillingFacts.ID }}"{{ end }}>Pay invoice</a>
{{ end }}
{{ if .CanKeep }}
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
<button type="button" class="btn btn-outline-primary btn-sm w-100 {{ if $pay }}mt-2{{ else }}mt-auto{{ end }}"
hx-post="/partials/member/plans/keep" hx-vals='{"ladder_id": "{{ .LadderID }}"}'
hx-target="#member-plans" hx-swap="innerHTML">Keep plan</button>
{{ end }}
{{ if .CanCancel }}
<button type="button" class="btn btn-outline-secondary btn-sm w-100{{ if not .CanKeep }} mt-auto{{ else }} mt-2{{ end }}"
<button type="button" class="btn btn-outline-secondary btn-sm w-100{{ if or $pay .CanKeep }} mt-2{{ else }} mt-auto{{ end }}"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/member/plans/cancel"
@@ -130,6 +130,15 @@ func TestPlansOfferPaidMoveGate(t *testing.T) {
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}}}}
paymentDue := []LadderViewModel{{Tiers: []TierViewModel{{Relation: "current", LadderID: "l1", Suspension: suspensionPaymentDue}}}}
paused := []LadderViewModel{{Tiers: []TierViewModel{{Relation: "current", LadderID: "l1", Suspension: suspensionPaused}}}}
unprojected := []LadderViewModel{{Tiers: []TierViewModel{{Relation: "current", Suspension: suspensionPaymentDue}}}}
if !plansOfferPaidMove(paymentDue) {
t.Error("a plan whose payment is due does not earn the line")
}
if plansOfferPaidMove(paused) || plansOfferPaidMove(unprojected) {
t.Error("a plan that draws no Pay invoice earned the line")
}
if !plansOfferPaidMove(checkout) {
t.Error("an enabled checkout does not earn the line")
}
@@ -141,6 +150,29 @@ func TestPlansOfferPaidMoveGate(t *testing.T) {
}
}
// Pay invoice leads to a page that charges a card, so it references the
// billing line as the paid moves do, and draws only on a plan whose payment
// is due and whose card knows its ladder.
func TestPayInvoiceReferencesTheBillingLine(t *testing.T) {
data := PlansData{
BillingFacts: BillingFacts{Sentences: shownFacts}.WithID(plansBillingFactsID),
Ladders: []LadderViewModel{{
Name: "Workshop access", Enrolled: true,
Tiers: []TierViewModel{{Name: "Pro", Relation: "current", LadderID: "l1", Suspension: suspensionPaymentDue, BillingPriceText: "$12.00/month"}},
}},
}
body := Render(t, MemberSet(t), "member_plans.html", data)
start := strings.Index(body, `<a href="/billing/plans/l1/unpaid-invoice"`)
if start < 0 {
t.Fatalf("the card draws no Pay invoice: %s", body)
}
tag := body[start:]
tag = tag[:strings.Index(tag, ">")]
if !strings.Contains(tag, `aria-describedby="plans-billing-facts"`) {
t.Error("Pay invoice does not reference the billing paragraph")
}
}
func TestSuccessReturnRepeatsTheFacts(t *testing.T) {
body := Render(t, MemberSet(t), "index.html", IndexPageData{CheckoutStatus: "success", BillingFacts: BillingFacts{Sentences: shownFacts}})
alert := body[strings.Index(body, "alert-success"):]
+271
View File
@@ -0,0 +1,271 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"context"
"database/sql"
"errors"
"log/slog"
"net/http"
"net/url"
"github.com/google/uuid"
stripe "github.com/stripe/stripe-go/v81"
stripeinvoice "github.com/stripe/stripe-go/v81/invoice"
stripesubscription "github.com/stripe/stripe-go/v81/subscription"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
)
// Pay invoice sends a member to Stripe's hosted page of an invoice. The page's
// URL is a bearer link: anyone holding it can open the invoice and pay it, it
// changes on every read, and it expires. The two routes below therefore read
// it from Stripe when the member opens the link and answer a redirect; the URL
// is never stored, never written to a log line, an error or a response body,
// and every answer carries Cache-Control: no-store so that no cache keeps it.
// billingPagePath is where a Pay invoice link lands when the console cannot
// or will not send the member to Stripe: the Billing page, whose detail of an
// open invoice offers Pay invoice when the key reaches its mapping.
const billingPagePath = "/billing"
// memberInvoice is the ownership gate of an invoice: the invoice must exist
// and its billing account must be one of the organization's. It answers
// http.StatusOK with the invoice, http.StatusNotFound for an unknown invoice
// or another organization's (indistinguishable, so a member cannot probe for
// other organizations' invoices), and http.StatusInternalServerError when the
// organization's billing accounts cannot be read. The invoice detail and the
// by-invoice Pay invoice route both ask it.
func (h *MemberInvoicesHandler) memberInvoice(ctx context.Context, orgID, invoiceID string) (billing.Invoice, int) {
invoice, err := h.BillingQ.GetInvoiceByID(ctx, invoiceID)
if err != nil {
// Not found (or an unparseable id) — do not distinguish from "not yours".
return billing.Invoice{}, http.StatusNotFound
}
owned, ok := h.orgOwnsAccount(ctx, orgID, invoice.BillingAccountID)
if !ok {
return billing.Invoice{}, http.StatusInternalServerError
}
if !owned {
return billing.Invoice{}, http.StatusNotFound
}
return invoice, http.StatusOK
}
// orgOwnsAccount reports whether billingAccountID is one of the
// organization's billing accounts; ok is false when the accounts cannot be
// read, which it logs.
func (h *MemberInvoicesHandler) orgOwnsAccount(ctx context.Context, orgID, billingAccountID string) (owned, ok bool) {
accounts, err := h.BillingQ.ListBillingAccountsByOrgID(ctx, orgID)
if err != nil {
h.Logger.Error("failed to list billing accounts",
slog.String("org_id", orgID), slog.Any("error", err))
return false, false
}
return invoiceBelongsToOrg(accounts, billingAccountID), true
}
// seeOther answers 303 to location with no body. The handlers set
// Cache-Control: no-store before they answer anything, so that no answer of
// theirs, a refusal included, depends on the stack around them. The body is
// left empty on purpose: net/http.Redirect would write the location into an
// HTML body, and the location here can be a bearer link.
func seeOther(w http.ResponseWriter, location string) {
w.Header().Set("Location", location)
w.WriteHeader(http.StatusSeeOther)
}
// hostedPage is the hosted invoice URL of an invoice Stripe returned: empty
// when Stripe returned none (an invoice not finalized), or one that is not an
// absolute https URL, which the console does not redirect a member to.
func hostedPage(inv *stripe.Invoice) string {
if inv == nil {
return ""
}
u, err := url.Parse(inv.HostedInvoiceURL)
if err != nil || u.Scheme != "https" || u.Host == "" {
return ""
}
return inv.HostedInvoiceURL
}
// GetHostedInvoice handles GET /billing/invoices/{invoiceID}/hosted. After
// the invoice detail's ownership gate (404 for an unknown invoice and for
// another organization's) it reads the invoice from Stripe and answers 303 to
// its hosted page, whatever the invoice's status, because the page of a paid
// invoice offers the invoice and its receipt. It answers 303 to /billing,
// calling nothing, with no Stripe querier, no mapping, or a mapping the key
// cannot reach, and 303 to /billing when Stripe returns no page or the read
// fails. It never writes the URL to a log line: the URL is a bearer link.
func (h *MemberInvoicesHandler) GetHostedInvoice(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
invoiceID := r.PathValue("invoiceID")
if _, status := h.memberInvoice(r.Context(), session.OrgID, invoiceID); status != http.StatusOK {
h.pageRefused(w, r, status)
return
}
seeOther(w, h.hostedInvoicePage(r.Context(), session.OrgID, invoiceID))
}
// pageRefused answers a Pay invoice route's gate refusal. The routes are
// navigations a member opens in a new tab, so a refusal is the styled error
// page (error-pages); the invoice detail, a partial, answers gateRefused.
func (h *MemberInvoicesHandler) pageRefused(w http.ResponseWriter, r *http.Request, status int) {
if status == http.StatusNotFound {
h.Templates.RenderErrorPage(w, r, http.StatusNotFound, "The page you requested does not exist.")
return
}
h.Templates.RenderErrorPage(w, r, http.StatusInternalServerError, "An unexpected error occurred. Try again.")
}
// gateRefused answers a gate's refusal for the invoice detail.
func (h *MemberInvoicesHandler) gateRefused(w http.ResponseWriter, r *http.Request, status int) {
if status == http.StatusNotFound {
http.NotFound(w, r)
return
}
http.Error(w, "Failed to load invoice", http.StatusInternalServerError)
}
// hostedInvoicePage is where the by-invoice route redirects: the invoice's
// hosted page, or the Billing page.
func (h *MemberInvoicesHandler) hostedInvoicePage(ctx context.Context, orgID, invoiceID string) string {
if h.StripeQ == nil {
return billingPagePath
}
mapping, err := h.StripeQ.GetInvoiceMappingByInvoiceID(ctx, invoiceID)
if err != nil || !mapping.StripeInvoiceID.Valid ||
!stripedb.MappingReachable(mapping.Livemode, mapping.SyncStatus, h.StripeMode) {
return billingPagePath
}
params := &stripe.InvoiceParams{}
params.Context = ctx
inv, err := stripeinvoice.Get(mapping.StripeInvoiceID.String, params)
if err != nil {
h.Logger.Warn("member hosted invoice: stripe read failed",
slog.String("org_id", orgID), slog.String("invoice_id", invoiceID), slog.Any("error", err))
return billingPagePath
}
if page := hostedPage(inv); page != "" {
return page
}
return billingPagePath
}
// GetPlanUnpaidInvoice handles GET /billing/plans/{ladderID}/unpaid-invoice,
// the current plan card's Pay invoice, for a card that knows the plan and not
// the invoice. The position the organization holds on the ladder is picked as
// every reader picks it (entitlements.PositionsByLadder); a malformed ladder
// id, no position, a position no subscription backs, and a subscription whose
// billing account is not the organization's each answer 404. A position that
// is not suspended answers 303 to /billing with no Stripe call, because the
// route exists to pay the invoice that suspended a plan, and an active
// subscription's open latest invoice can be an upgrade the member walked away
// from, which paying would apply. Otherwise it reads the subscription with
// its latest invoice expanded and answers 303 to that invoice's hosted page
// when the invoice is open, and 303 to /billing otherwise. It never writes
// the URL to a log line: the URL is a bearer link.
func (h *MemberInvoicesHandler) GetPlanUnpaidInvoice(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
ladderID := r.PathValue("ladderID")
if _, err := uuid.Parse(ladderID); err != nil {
h.pageRefused(w, r, http.StatusNotFound)
return
}
pos, status := h.planPosition(r.Context(), session.OrgID, ladderID)
if status != http.StatusOK {
h.pageRefused(w, r, status)
return
}
if pos.PositionStatus != "suspended" {
seeOther(w, billingPagePath)
return
}
seeOther(w, h.unpaidInvoicePage(r.Context(), session.OrgID, pos.SubscriptionID.UUID.String()))
}
// planPosition is the by-plan route's gate: the organization's position on
// the ladder, which a subscription backs and whose billing account is the
// organization's. It answers http.StatusOK with the position,
// http.StatusNotFound for every other case that is the member's to know
// nothing about, and http.StatusInternalServerError when a read fails.
func (h *MemberInvoicesHandler) planPosition(ctx context.Context, orgID, ladderID string) (entitlements.ListLadderPositionsByPoolRow, int) {
none := entitlements.ListLadderPositionsByPoolRow{}
pool, err := h.EntitlementsQ.GetDefaultPoolByOrgID(ctx, orgID)
if errors.Is(err, sql.ErrNoRows) {
return none, http.StatusNotFound
}
if err != nil {
h.Logger.Error("failed to read the default pool", slog.String("org_id", orgID), slog.Any("error", err))
return none, http.StatusInternalServerError
}
rows, err := h.EntitlementsQ.ListLadderPositionsByPool(ctx, pool.PoolID)
if err != nil {
h.Logger.Error("failed to read the positions", slog.String("org_id", orgID), slog.Any("error", err))
return none, http.StatusInternalServerError
}
pos, ok := entitlements.PositionsByLadder(rows)[ladderID]
if !ok || !pos.SubscriptionID.Valid {
return none, http.StatusNotFound
}
sub, err := h.BillingQ.GetSubscriptionByID(ctx, pos.SubscriptionID.UUID.String())
if errors.Is(err, sql.ErrNoRows) {
return none, http.StatusNotFound
}
if err != nil {
h.Logger.Error("failed to read the subscription",
slog.String("org_id", orgID), slog.String("subscription_id", pos.SubscriptionID.UUID.String()), slog.Any("error", err))
return none, http.StatusInternalServerError
}
owned, ok := h.orgOwnsAccount(ctx, orgID, sub.BillingAccountID)
switch {
case !ok:
return none, http.StatusInternalServerError
case !owned:
return none, http.StatusNotFound
}
return pos, http.StatusOK
}
// unpaidInvoicePage is where the by-plan route redirects for a suspended
// position: the hosted page of the subscription's latest invoice when that
// invoice is open, the Billing page otherwise.
func (h *MemberInvoicesHandler) unpaidInvoicePage(ctx context.Context, orgID, subscriptionID string) string {
if h.StripeQ == nil {
return billingPagePath
}
mapping, err := h.StripeQ.GetSubscriptionMappingBySubscriptionID(ctx, subscriptionID)
if err != nil || !mapping.StripeSubscriptionID.Valid ||
!stripedb.MappingReachable(mapping.Livemode, mapping.SyncStatus, h.StripeMode) {
return billingPagePath
}
params := &stripe.SubscriptionParams{Expand: []*string{stripe.String("latest_invoice")}}
params.Context = ctx
sub, err := stripesubscription.Get(mapping.StripeSubscriptionID.String, params)
if err != nil {
h.Logger.Warn("member hosted invoice: stripe read failed",
slog.String("org_id", orgID), slog.String("subscription_id", subscriptionID), slog.Any("error", err))
return billingPagePath
}
if sub.LatestInvoice == nil || sub.LatestInvoice.Status != stripe.InvoiceStatusOpen {
return billingPagePath
}
if page := hostedPage(sub.LatestInvoice); page != "" {
return page
}
return billingPagePath
}
@@ -0,0 +1,416 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server_test
// Pay invoice (openspec change suspended-plans-stay-held, design D8): the two
// member routes read Stripe's hosted invoice page when the member opens the
// link and answer 303 to it, against a real Postgres and the Stripe mock. The
// tests call the handlers directly through authtest, not through the
// middleware stack, so that a handler's own Cache-Control line is tested. The
// hosted URL is a bearer link: every row checks that no captured log record
// carries it.
import (
"context"
"errors"
"log/slog"
"maps"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
stripe "github.com/stripe/stripe-go/v81"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
"git.coopcloud.tech/wiki-cafe/member-console/internal/stripetest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/authtest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world"
)
const (
hostedStripeFailed = "member hosted invoice: stripe read failed"
readRefused = "stripetest: read refused"
)
// hostedFixture is an organization with invoices and suspended plans in every
// state the Pay invoice routes tell apart, and a second organization's invoice.
type hostedFixture struct {
t *testing.T
orgID string
w *world.World
mock *stripetest.MockBackend
logs *capturedLog
h *server.MemberInvoicesHandler
ctx context.Context
token string
invoices map[string]world.Invoice
ladders map[string]world.Ladder
subs map[string]world.Subscription
}
// url is the hosted URL the mock serves for the named invoice; every one
// carries the test's own token, so a log record that holds any of them is
// found by the token.
func (f *hostedFixture) url(name string) string {
return "https://invoice.stripe.com/i/acct_test/" + f.token + "_" + name
}
func newHostedFixture(t *testing.T) *hostedFixture {
t.Helper()
db := dbtest.Open(t)
w := world.New(t, db)
f := &hostedFixture{
t: t, w: w, token: "tok" + w.Sfx, logs: &capturedLog{},
mock: &stripetest.MockBackend{Invoices: map[string]*stripe.Invoice{}},
invoices: map[string]world.Invoice{}, ladders: map[string]world.Ladder{}, subs: map[string]world.Subscription{},
}
t.Cleanup(stripetest.Install(f.mock))
orgType := w.OrgType("hosted")
person := w.Person("Hosted Member")
org := w.Org("Hosted Org", orgType, person)
other := w.Org("Other Org", orgType, w.Person("Other Member"))
account := w.BillingAccount(org, "Hosted")
otherAccount := w.BillingAccount(other, "Other")
f.orgID = org.ID
f.addInvoice("open", account, "open", boolPtr(false), stripe.InvoiceStatusOpen, true)
f.addInvoice("paid", account, "paid", boolPtr(false), stripe.InvoiceStatusPaid, true)
f.addInvoice("other-org", otherAccount, "open", boolPtr(false), stripe.InvoiceStatusOpen, true)
f.addInvoice("other-env", account, "open", boolPtr(true), stripe.InvoiceStatusOpen, true)
f.addInvoice("unmapped", account, "open", nil, stripe.InvoiceStatusOpen, true)
f.addInvoice("error", account, "open", boolPtr(false), stripe.InvoiceStatusOpen, true)
f.addInvoice("no-url", account, "open", boolPtr(false), stripe.InvoiceStatusOpen, false)
f.addInvoice("plain-http", account, "open", boolPtr(false), stripe.InvoiceStatusOpen, false)
f.mock.Invoices["in_"+f.invoices["plain-http"].ID].HostedInvoiceURL = "http://invoice.stripe.com/i/acct_test/" + f.token + "_plain-http"
f.addPlan(org, "main", "past_due", boolPtr(false))
f.addPlan(org, "unmapped", "past_due", nil)
f.addPlan(org, "other-env", "past_due", boolPtr(true))
f.addPlan(org, "failed", "past_due", boolPtr(false))
f.addPlan(org, "active", "", boolPtr(false))
f.addPlan(other, "other-org", "past_due", boolPtr(false))
f.ladders["nobody"] = w.Ladder("Nobody", w.Product("Nobody"))
sm := authtest.NewSessionManager()
h, err := server.NewMemberInvoicesHandler(server.MemberInvoicesConfig{
BillingQ: billing.New(db), EntitlementsQ: entitlements.New(db), StripeQ: internalstripe.New(db),
AuthConfig: &auth.Config{SessionManager: sm}, Logger: slog.New(f.logs), StripeMode: "test",
})
if err != nil {
t.Fatalf("new handler: %v", err)
}
f.h = h
signIn := person.SignIn()
signIn.OrgID = org.ID
f.ctx = authtest.SignIn(t, sm, signIn)
return f
}
func boolPtr(b bool) *bool { return &b }
// reset forgets the records so far.
func (l *capturedLog) reset() {
l.mu.Lock()
defer l.mu.Unlock()
l.list = nil
}
// addInvoice creates an invoice of the console in the status given, maps it in
// the environment mapped names (unmapped when nil), and serves its Stripe
// twin from the mock, with a hosted URL when withURL.
func (f *hostedFixture) addInvoice(name string, account world.Account, status string, mapped *bool, stripeStatus stripe.InvoiceStatus, withURL bool) {
f.t.Helper()
inv := f.w.Invoice(account, world.InvoiceOpts{Status: status})
f.invoices[name] = inv
if mapped != nil {
f.w.MapInvoiceIn(inv, *mapped)
}
twin := &stripe.Invoice{ID: "in_" + inv.ID, Status: stripeStatus}
if withURL {
twin.HostedInvoiceURL = f.url(name)
}
f.mock.Invoices[twin.ID] = twin
}
// addPlan gives org a plan on a ladder of its own, held by a subscription
// that is suspended with the status given (left active when empty) and mapped
// in the environment mapped names (unmapped when nil).
func (f *hostedFixture) addPlan(org world.Org, name, suspended string, mapped *bool) {
f.t.Helper()
product := f.w.Product(name)
f.w.Price(product, 1200)
f.ladders[name] = f.w.Ladder(name, product)
sub := f.w.Subscribe(org, world.SubOpts{}, product)
f.subs[name] = sub
if suspended != "" {
f.w.Suspend(sub, suspended)
}
if mapped != nil {
f.w.MapSubscriptionIn(sub, *mapped)
}
}
// latest serves the named plan's Stripe subscription with a latest invoice of
// the status given and the hosted URL of the invoice named url.
func (f *hostedFixture) latest(plan string, status stripe.InvoiceStatus, url string) {
f.mock.Sub = &stripe.Subscription{
ID: "sub_" + f.subs[plan].ID,
LatestInvoice: &stripe.Invoice{
ID: "in_latest_" + plan, Status: status, HostedInvoiceURL: f.url(url),
},
}
}
// hostedRow is one request to a Pay invoice route and what it must answer.
type hostedRow struct {
name string
// plan is true for the by-plan route, false for the by-invoice route.
plan bool
// target names the invoice or the ladder, or is the raw id when raw.
target string
raw bool
// prepare sets what the mock serves.
prepare func(f *hostedFixture)
status int
// location is the hosted URL's name, or "" for /billing.
location string
// urlDesc names the URL in the failure text.
urlDesc string
// noCall, when set, says what the route must not call Stripe for.
noCall string
// logged is true when the row's failed read must be logged.
logged bool
// logAttrs gives the attributes beyond org_id and error that the logged
// record must carry; the ids exist only once the fixture is built.
logAttrs func(f *hostedFixture) map[string]string
// expand is true when the route must expand latest_invoice.
expand bool
}
var hostedRows = []hostedRow{
{name: "an open invoice", target: "open", status: 303, location: "open", urlDesc: "its hosted URL"},
{name: "a paid invoice", target: "paid", status: 303, location: "paid", urlDesc: "the paid invoice's hosted URL"},
{name: "another organization's invoice", target: "other-org", status: 404, noCall: "an invoice of another organization"},
{name: "an unknown invoice", target: "no-such-invoice", raw: true, status: 404, noCall: "an unknown invoice"},
{name: "an invoice mapped in the other environment", target: "other-env", status: 303, noCall: "an invoice the key cannot reach"},
{name: "an unmapped invoice", target: "unmapped", status: 303, noCall: "an unmapped invoice"},
{name: "an invoice whose read fails", target: "error", status: 303, logged: true, prepare: failInvoiceRead,
logAttrs: func(f *hostedFixture) map[string]string {
return map[string]string{"invoice_id": f.invoices["error"].ID}
}},
{name: "an invoice with no hosted URL", target: "no-url", status: 303},
{name: "an invoice whose hosted URL is not https", target: "plain-http", status: 303, urlDesc: "a URL that is not https"},
{name: "a suspended plan with an open latest invoice", plan: true, target: "main", status: 303, location: "latest-open", urlDesc: "the open invoice's hosted URL", expand: true,
prepare: func(f *hostedFixture) { f.latest("main", stripe.InvoiceStatusOpen, "latest-open") }},
{name: "a suspended plan with a paid latest invoice", plan: true, target: "main", status: 303, urlDesc: "the paid invoice's hosted URL",
prepare: func(f *hostedFixture) { f.latest("main", stripe.InvoiceStatusPaid, "latest-paid") }},
{name: "a ladder the organization holds no plan on", plan: true, target: "nobody", status: 404, noCall: "a ladder with no plan"},
{name: "another organization's plan", plan: true, target: "other-org", status: 404, noCall: "a plan of another organization"},
{name: "an id that is not a ladder", plan: true, target: "not-a-uuid", raw: true, status: 404, noCall: "an id that is not a ladder"},
{name: "a subscription with no mapping", plan: true, target: "unmapped", status: 303, noCall: "a subscription with no mapping"},
{name: "a subscription mapped in the other environment", plan: true, target: "other-env", status: 303, noCall: "a subscription the key cannot reach"},
{name: "a subscription whose read fails", plan: true, target: "failed", status: 303, logged: true, prepare: failSubscriptionRead,
logAttrs: func(f *hostedFixture) map[string]string {
return map[string]string{"subscription_id": f.subs["failed"].ID}
}},
{name: "a plan that is not suspended", plan: true, target: "active", status: 303, noCall: "a plan that is not suspended"},
}
func failInvoiceRead(f *hostedFixture) {
f.mock.ObjectErrs = map[string]error{"in_" + f.invoices["error"].ID: errors.New(readRefused)}
}
func failSubscriptionRead(f *hostedFixture) {
f.mock.PathErrs = map[string]error{"/v1/subscriptions/sub_" + f.subs["failed"].ID: errors.New(readRefused)}
}
func TestHostedInvoiceRoutes(t *testing.T) {
f := newHostedFixture(t)
for _, row := range hostedRows {
t.Run(row.name, func(t *testing.T) { f.check(t, row) })
}
}
// check sends the row's request and compares the answer with the row.
func (f *hostedFixture) check(t *testing.T, row hostedRow) {
t.Helper()
f.mock.Err, f.mock.Sub, f.mock.PathErrs, f.mock.ObjectErrs = nil, nil, nil, nil
if row.prepare != nil {
row.prepare(f)
}
f.logs.reset()
before := len(f.mock.Calls)
rec := f.request(t, row)
calls := f.mock.Calls[before:]
if rec.Code != row.status {
t.Fatalf("status = %d, want %d", rec.Code, row.status)
}
if row.noCall != "" && len(calls) != 0 {
t.Errorf("the route called Stripe for %s", row.noCall)
}
if row.status == http.StatusSeeOther {
f.checkRedirect(t, row, rec)
}
if row.expand && !expandsLatestInvoice(calls) {
t.Error("the subscription read carried no expand of latest_invoice")
}
if row.logged && !f.loggedFailedRead(row) {
t.Error("no record of copy row 15 was logged")
}
for _, r := range f.logs.records() {
if f.carriesURL(r) {
t.Errorf("a log record carries the hosted URL: %s", r.message)
}
}
}
// request sends the row's request to its route.
func (f *hostedFixture) request(t *testing.T, row hostedRow) *httptest.ResponseRecorder {
t.Helper()
if row.plan {
id := row.target
if !row.raw {
id = f.ladders[row.target].ID
}
return authtest.Get(t, f.ctx, f.h.GetPlanUnpaidInvoice, "/billing/plans/"+id+"/unpaid-invoice", authtest.PathValue("ladderID", id))
}
id := row.target
if !row.raw {
id = f.invoices[row.target].ID
}
return authtest.Get(t, f.ctx, f.h.GetHostedInvoice, "/billing/invoices/"+id+"/hosted", authtest.PathValue("invoiceID", id))
}
// checkRedirect compares a 303's Location and Cache-Control with the row.
func (f *hostedFixture) checkRedirect(t *testing.T, row hostedRow, rec *httptest.ResponseRecorder) {
t.Helper()
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Errorf("Cache-Control = %q, want \"no-store\"", got)
}
got := rec.Header().Get("Location")
switch {
case row.location == "" && got != "/billing" && (row.urlDesc == "" || !strings.Contains(got, f.token)):
t.Errorf("Location = %q, want %q", got, "/billing")
case row.location == "" && got != "/billing":
t.Errorf("Location is %s, want %q", row.urlDesc, "/billing")
case row.location != "" && got != f.url(row.location):
t.Errorf("Location = %q, want %s", got, row.urlDesc)
}
if strings.Contains(rec.Body.String(), f.token) {
t.Error("the redirect's body carries the hosted URL")
}
}
// expandsLatestInvoice reports whether a recorded subscription read asked for
// the latest invoice to be expanded, in whichever indexed form the SDK
// encodes it.
func expandsLatestInvoice(calls []stripetest.Call) bool {
for _, c := range calls {
if c.Method != http.MethodGet || !strings.HasPrefix(c.Path, "/v1/subscriptions/") {
continue
}
for k, v := range c.Form {
if strings.HasPrefix(k, "expand") && slices.Contains(v, "latest_invoice") {
return true
}
}
}
return false
}
// loggedFailedRead reports whether copy row 15 was logged at Warn with the
// organization, the error and the id of what was read.
func (f *hostedFixture) loggedFailedRead(row hostedRow) bool {
want := map[string]string{"org_id": f.orgID, "error": readRefused}
if row.logAttrs != nil {
maps.Copy(want, row.logAttrs(f))
}
for _, r := range f.logs.records() {
if r.message != hostedStripeFailed || r.level != slog.LevelWarn {
continue
}
matches := true
for k, v := range want {
if r.attrs[k] != v {
matches = false
}
}
if matches {
return true
}
}
return false
}
// carriesURL reports whether a captured record holds a hosted URL in its
// message or any attribute.
func (f *hostedFixture) carriesURL(r capturedRecord) bool {
if strings.Contains(r.message, f.token) {
return true
}
for _, v := range r.attrs {
if strings.Contains(v, f.token) {
return true
}
}
return false
}
// TestInvoiceDetailOffersPayInvoice: the detail of an open invoice whose
// mapping the key reaches links to its hosted route; a paid invoice, an open
// one mapped in the other environment and an unmapped one show none.
func TestInvoiceDetailOffersPayInvoice(t *testing.T) {
f := newHostedFixture(t)
rows := []struct {
invoice, failure string
want bool
}{
{"open", "the detail offers no Pay invoice for an open invoice the key reaches", true},
{"paid", "the detail offers Pay invoice for a paid invoice", false},
{"other-env", "the detail offers Pay invoice for an invoice the key cannot reach", false},
{"unmapped", "the detail offers Pay invoice for an unmapped invoice", false},
}
for _, row := range rows {
t.Run(row.invoice, func(t *testing.T) {
id := f.invoices[row.invoice].ID
rec := authtest.Get(t, f.ctx, f.h.GetInvoiceDetail, "/partials/member/invoices/"+id, authtest.PathValue("invoiceID", id))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
links := htmltest.Parse(t, rec.Body.String()).All("a[href$='/hosted']")
if row.want != (len(links) == 1) {
t.Fatalf("%s: %d links to a hosted route", row.failure, len(links))
}
if row.want {
checkPayInvoiceLink(t, links[0], "/billing/invoices/"+id+"/hosted")
}
})
}
}
// checkPayInvoiceLink compares the detail's link with copy row 11.
func checkPayInvoiceLink(t *testing.T, link *htmltest.Node, href string) {
t.Helper()
if got := link.Text(); got != "Pay invoice" {
t.Errorf("the link reads %q, want \"Pay invoice\"", got)
}
for attr, want := range map[string]string{"href": href, "target": "_blank", "rel": "noopener", "hx-boost": "false"} {
if got, _ := link.Attr(attr); got != want {
t.Errorf("the link's %s = %q, want %q", attr, got, want)
}
}
}
+49 -40
View File
@@ -8,10 +8,12 @@ import (
"database/sql"
"log/slog"
"net/http"
"net/url"
"sort"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
)
@@ -19,13 +21,17 @@ import (
// MemberInvoicesHandler handles HTMX partial requests for the member Billing
// page (invoice and payment history). The full /billing page itself is served
// in server.go alongside /products, since it uses the page-level template set;
// this handler owns the data-bearing partials.
// this handler owns the data-bearing partials and Pay invoice's two redirects
// (member_hosted_invoice.go).
type MemberInvoicesHandler struct {
BillingQ billing.Querier
StripeQ stripedb.Querier
AuthConfig *auth.Config
Logger *slog.Logger
Templates *SafeTemplates
BillingQ billing.Querier
// EntitlementsQ resolves the position behind Pay invoice's by-plan
// route.
EntitlementsQ entitlements.Querier
StripeQ stripedb.Querier
AuthConfig *auth.Config
Logger *slog.Logger
Templates *SafeTemplates
// StripeMode is the environment derived from the API key at boot, the
// same value the operator views read. The member's own invoices are
// filtered to it with no switch: a member has no business with the
@@ -35,11 +41,12 @@ type MemberInvoicesHandler struct {
// MemberInvoicesConfig holds configuration for the member invoices handler.
type MemberInvoicesConfig struct {
BillingQ billing.Querier
StripeQ stripedb.Querier
AuthConfig *auth.Config
Logger *slog.Logger
StripeMode string
BillingQ billing.Querier
EntitlementsQ entitlements.Querier
StripeQ stripedb.Querier
AuthConfig *auth.Config
Logger *slog.Logger
StripeMode string
}
// NewMemberInvoicesHandler creates a new MemberInvoicesHandler.
@@ -50,12 +57,13 @@ func NewMemberInvoicesHandler(cfg MemberInvoicesConfig) (*MemberInvoicesHandler,
}
return &MemberInvoicesHandler{
BillingQ: cfg.BillingQ,
StripeQ: cfg.StripeQ,
AuthConfig: cfg.AuthConfig,
Logger: cfg.Logger,
Templates: NewSafeTemplates(tmpl, cfg.Logger),
StripeMode: cfg.StripeMode,
BillingQ: cfg.BillingQ,
EntitlementsQ: cfg.EntitlementsQ,
StripeQ: cfg.StripeQ,
AuthConfig: cfg.AuthConfig,
Logger: cfg.Logger,
Templates: NewSafeTemplates(tmpl, cfg.Logger),
StripeMode: cfg.StripeMode,
}, nil
}
@@ -63,6 +71,11 @@ func NewMemberInvoicesHandler(cfg MemberInvoicesConfig) (*MemberInvoicesHandler,
func (h *MemberInvoicesHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /partials/member/invoices", h.GetInvoices)
mux.HandleFunc("GET /partials/member/invoices/{invoiceID}", h.GetInvoiceDetail)
// Pay invoice (member_hosted_invoice.go): each route reads the URL of
// Stripe's hosted invoice page when the member opens the link and answers
// a redirect. Like GET /billing they sit behind the sign-in requirement.
mux.HandleFunc("GET /billing/invoices/{invoiceID}/hosted", h.GetHostedInvoice)
mux.HandleFunc("GET /billing/plans/{ladderID}/unpaid-invoice", h.GetPlanUnpaidInvoice)
}
// --- View models ---
@@ -124,9 +137,14 @@ type MemberInvoiceDetailData struct {
Period string
DueDate string
PaidAt string
LineItems []MemberLineItemViewModel
Payments []MemberPaymentViewModel
Error string
// PayPath is the console route that leads to Stripe's hosted page of the
// invoice, set for an open invoice whose Stripe mapping the key reaches;
// empty draws no Pay invoice. It is a path of this console, never the
// hosted URL, which is never stored.
PayPath string
LineItems []MemberLineItemViewModel
Payments []MemberPaymentViewModel
Error string
}
// invoiceBelongsToOrg reports whether billingAccountID is one of the
@@ -290,24 +308,9 @@ func (h *MemberInvoicesHandler) GetInvoiceDetail(w http.ResponseWriter, r *http.
invoiceID := r.PathValue("invoiceID")
invoice, err := h.BillingQ.GetInvoiceByID(r.Context(), invoiceID)
if err != nil {
// Not found (or an unparseable id) — do not distinguish from "not yours".
http.NotFound(w, r)
return
}
// Ownership check: the invoice's billing account must belong to the
// member's organization, else 404 (no existence leak).
accounts, err := h.BillingQ.ListBillingAccountsByOrgID(r.Context(), session.OrgID)
if err != nil {
h.Logger.Error("failed to list billing accounts",
slog.String("org_id", session.OrgID), slog.Any("error", err))
http.Error(w, "Failed to load invoice", http.StatusInternalServerError)
return
}
if !invoiceBelongsToOrg(accounts, invoice.BillingAccountID) {
http.NotFound(w, r)
invoice, status := h.memberInvoice(r.Context(), session.OrgID, invoiceID)
if status != http.StatusOK {
h.gateRefused(w, r, status)
return
}
@@ -329,8 +332,14 @@ func (h *MemberInvoicesHandler) GetInvoiceDetail(w http.ResponseWriter, r *http.
}
if h.StripeQ != nil {
if mapping, err := h.StripeQ.GetInvoiceMappingByInvoiceID(r.Context(), invoiceID); err == nil && mapping.StripeInvoiceNumber.Valid {
data.StripeInvoiceNumber = mapping.StripeInvoiceNumber.String
if mapping, err := h.StripeQ.GetInvoiceMappingByInvoiceID(r.Context(), invoiceID); err == nil {
if mapping.StripeInvoiceNumber.Valid {
data.StripeInvoiceNumber = mapping.StripeInvoiceNumber.String
}
if invoice.Status == "open" && mapping.StripeInvoiceID.Valid &&
stripedb.MappingReachable(mapping.Livemode, mapping.SyncStatus, h.StripeMode) {
data.PayPath = "/billing/invoices/" + url.PathEscape(invoiceID) + "/hosted"
}
}
}
+17 -5
View File
@@ -201,6 +201,13 @@ type TierViewModel struct {
Held bool
}
// PaysInvoice reports whether the card offers Pay invoice: a projected plan
// (LadderID set) whose payment is due. The template and the billing line read
// this one condition.
func (t TierViewModel) PaysInvoice() bool {
return t.LadderID != "" && t.Suspension == suspensionPaymentDue
}
// DisabledControl builds the shared disabledControl part's data (design
// D10, ACC-8) for a tier whose move is shown but not enabled: the member
// surface's visible-reason variant (AsVisible), not the operator surface's
@@ -572,17 +579,22 @@ func currentTier(lvm *LadderViewModel) TierViewModel {
return TierViewModel{}
}
// 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.
// plansOfferPaidMove reports whether any tier's control is enabled and leads
// to a page that charges a card: a checkout, a switch, or the Pay invoice of a
// current tier whose payment is due (design D8; it opens Stripe's hosted
// invoice page, as Approve payment does). All of them charge the card on file
// or take a new one, 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
}
if t.Relation == "current" && t.PaysInvoice() {
return true
}
}
}
return false
+37 -3
View File
@@ -30,6 +30,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"sync"
"testing"
@@ -487,13 +488,46 @@ type capturedLog struct {
}
func (*capturedLog) Enabled(context.Context, slog.Level) bool { return true }
func (l *capturedLog) WithAttrs([]slog.Attr) slog.Handler { return l }
func (l *capturedLog) WithGroup(string) slog.Handler { return l }
func (l *capturedLog) WithAttrs(as []slog.Attr) slog.Handler {
return &capturedWith{base: l, attrs: as}
}
func (l *capturedLog) WithGroup(string) slog.Handler { return l }
// capturedWith is a capturedLog derived by Logger.With: every record it
// handles carries the attributes given to With, so a test sees them.
type capturedWith struct {
base slog.Handler
attrs []slog.Attr
}
func (c *capturedWith) Enabled(ctx context.Context, level slog.Level) bool {
return c.base.Enabled(ctx, level)
}
func (c *capturedWith) WithAttrs(as []slog.Attr) slog.Handler {
return &capturedWith{base: c.base, attrs: append(slices.Clone(c.attrs), as...)}
}
func (c *capturedWith) WithGroup(name string) slog.Handler {
return &capturedWith{base: c.base.WithGroup(name), attrs: c.attrs}
}
func (c *capturedWith) Handle(ctx context.Context, r slog.Record) error {
r = r.Clone()
r.AddAttrs(c.attrs...)
return c.base.Handle(ctx, r)
}
func (l *capturedLog) Handle(_ context.Context, r slog.Record) error {
rec := capturedRecord{level: r.Level, message: r.Message, attrs: map[string]string{}}
// A key a record repeats (a With attribute beside the line's own) keeps
// every value, so a check for a value cannot miss the shadowed one.
r.Attrs(func(a slog.Attr) bool {
rec.attrs[a.Key] = a.Value.String()
if old, ok := rec.attrs[a.Key]; ok {
rec.attrs[a.Key] = old + " " + a.Value.String()
} else {
rec.attrs[a.Key] = a.Value.String()
}
return true
})
l.mu.Lock()
+6 -5
View File
@@ -646,11 +646,12 @@ func registerCoreHandlers(httpRequestRouter *http.ServeMux, cfg Config, authConf
// Register Member Invoices partials handler (Billing page)
memberInvoicesHandler, err := NewMemberInvoicesHandler(MemberInvoicesConfig{
BillingQ: cfg.BillingQ,
StripeQ: cfg.StripeQ,
AuthConfig: authConfig,
Logger: cfg.Logger,
StripeMode: cfg.StripeMode,
BillingQ: cfg.BillingQ,
EntitlementsQ: cfg.EntitlementsQ,
StripeQ: cfg.StripeQ,
AuthConfig: authConfig,
Logger: cfg.Logger,
StripeMode: cfg.StripeMode,
})
if err != nil {
cfg.Logger.Error("failed to set up Member Invoices handler", slog.Any("error", err))
@@ -137,8 +137,10 @@
</ul>
<a href="/billing/plans/<ladder:Wiki>/unpaid-invoice" target="_blank" rel="noopener" hx-boost="false" class="btn btn-outline-primary btn-sm w-100 mt-auto">Pay invoice</a>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-2"
hx-post="/partials/member/plans/keep" hx-vals='{"ladder_id": "<ladder:Wiki>"}'
hx-target="#member-plans" hx-swap="innerHTML">Keep plan</button>
@@ -154,9 +154,11 @@
</ul>
<a href="/billing/plans/<ladder:Wiki>/unpaid-invoice" target="_blank" rel="noopener" hx-boost="false" class="btn btn-outline-primary btn-sm w-100 mt-auto">Pay invoice</a>
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-auto"
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-2"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/member/plans/cancel"
@@ -154,8 +154,10 @@
</ul>
<a href="/billing/plans/<ladder:Wiki>/unpaid-invoice" target="_blank" rel="noopener" hx-boost="false" class="btn btn-outline-primary btn-sm w-100 mt-auto">Pay invoice</a>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-2"
hx-post="/partials/member/plans/keep" hx-vals='{"ladder_id": "<ladder:Wiki>"}'
hx-target="#member-plans" hx-swap="innerHTML">Keep plan</button>
@@ -154,9 +154,11 @@
</ul>
<a href="/billing/plans/<ladder:Wiki>/unpaid-invoice" target="_blank" rel="noopener" hx-boost="false" class="btn btn-outline-primary btn-sm w-100 mt-auto">Pay invoice</a>
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-auto"
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-2"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/member/plans/cancel"
@@ -137,9 +137,11 @@
</ul>
<a href="/billing/plans/<ladder:Wiki>/unpaid-invoice" target="_blank" rel="noopener" hx-boost="false" class="btn btn-outline-primary btn-sm w-100 mt-auto">Pay invoice</a>
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-auto"
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-2"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/member/plans/cancel"
@@ -154,9 +154,11 @@
</ul>
<a href="/billing/plans/<ladder:Wiki>/unpaid-invoice" target="_blank" rel="noopener" hx-boost="false" class="btn btn-outline-primary btn-sm w-100 mt-auto">Pay invoice</a>
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-auto"
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-2"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/member/plans/cancel"
+3 -1
View File
@@ -154,9 +154,11 @@
</ul>
<a href="/billing/plans/<ladder:Wiki>/unpaid-invoice" target="_blank" rel="noopener" hx-boost="false" class="btn btn-outline-primary btn-sm w-100 mt-auto">Pay invoice</a>
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-auto"
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-2"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/member/plans/cancel"
+2
View File
@@ -4,6 +4,8 @@
/static/ 200
/static/fake/ 200
GET /billing 200 h1=Billing
GET /billing/invoices/{invoiceID}/hosted 404 h1=404 Not Found
GET /billing/plans/{ladderID}/unpaid-invoice 404 h1=404 Not Found
GET /domains 302 loc=/#domains first=<a href="/#domains">
GET /domains/ask 400 h1=400 Bad Request
GET /hooks/card 200
+4
View File
@@ -38,6 +38,10 @@ DELETE /partials/operator/plan-ladders/{ladderID}/tiers/{productID} probe=/part
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /billing probe=/billing pattern=GET /billing
anon: 302 loc=/login type=text/html h=nosniff,rid
GET /billing/invoices/{invoiceID}/hosted probe=/billing/invoices/00000000-0000-0000-0000-000000000000/hosted pattern=GET /billing/invoices/{invoiceID}/hosted
anon: 302 loc=/login type=text/html h=nosniff,rid
GET /billing/plans/{ladderID}/unpaid-invoice probe=/billing/plans/00000000-0000-0000-0000-000000000000/unpaid-invoice pattern=GET /billing/plans/{ladderID}/unpaid-invoice
anon: 302 loc=/login type=text/html h=nosniff,rid
GET /domains probe=/domains pattern=GET /domains
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 302 loc=/#domains type=text/html h=nosniff,rid
+4
View File
@@ -38,6 +38,10 @@ DELETE /partials/operator/plan-ladders/{ladderID}/tiers/{productID} probe=/part
member: 403 type=text/plain text=Forbidden h=nosniff,rid
GET /billing probe=/billing pattern=GET /billing
anon: 302 loc=/login type=text/html h=nosniff,rid
GET /billing/invoices/{invoiceID}/hosted probe=/billing/invoices/00000000-0000-0000-0000-000000000000/hosted pattern=GET /billing/invoices/{invoiceID}/hosted
anon: 302 loc=/login type=text/html h=nosniff,rid
GET /billing/plans/{ladderID}/unpaid-invoice probe=/billing/plans/00000000-0000-0000-0000-000000000000/unpaid-invoice pattern=GET /billing/plans/{ladderID}/unpaid-invoice
anon: 302 loc=/login type=text/html h=nosniff,rid
GET /domains probe=/domains pattern=GET /domains
anon: 302 loc=/login type=text/html h=nosniff,rid
member: 302 loc=/#domains type=text/html h=nosniff,rid
@@ -160,8 +160,8 @@ Reason: seven readers changing at once would make one golden diff of seven behav
- The reconcile's suspended branch mirrors no item and converges no scheduled change, as today. Conferring while suspended would deliver an unpaid plan; the recovery converges both. A period-end downgrade that lands during `past_due` keeps its row scheduled until the recovery, and the sweeper logs "sweep: period-end downgrade still scheduled a day after it was due" for it each run after a day (`internal/fulfillment/sweep.go:146-155`). Accepted: the row closes on recovery or with the subscription's end. The same branch is why a payment-due card can name a plan the subscription has already left. A downgrade that landed is converged by the reconcile of the renewal's `customer.subscription.updated` while the subscription is still active (D6), so it stays unconverged in the suspended branch only when no reconcile ran in the hour before Stripe charged the invoice. Then the card keeps the plan the position holds (D7 drops its "moves to" line and Keep plan, D6 refuses Keep plan), and the price line reads the subscription as the card does today.
- The sweeper fires a term-boundary cancellation or switch whatever the subscription's status. A term-boundary switch needs a commitment, which no console path writes. Note only.
- Every active-only read listed in Context besides `ListLadderPositionsByPool`: materialization, the reduction policy, the rematerialization population, the grants' delivery states and the operator counts.
- The add-ons page: products on no ladder stack by design and the page reads no holdings, so a subscriber of an add-on whose subscription is suspended is offered it again and sees no state, and on recovery both subscriptions deliver and bill, as today (F31; candidate issue in the proposal).
- The code's classification of `past_due` as suspending. The design documents describe a grace period; changing the code is the candidate issue the proposal names, and `design/` is not edited.
- The add-ons page: products on no ladder stack by design and the page reads no holdings, so a subscriber of an add-on whose subscription is suspended is offered it again and sees no state, and on recovery both subscriptions deliver and bill, as today (F31; #258).
- The code's classification of `past_due` as suspending. The design documents describe a grace period; changing the code is #255's question, and `design/` is not edited.
### D15. Docs
@@ -189,7 +189,7 @@ Each doc moves with the section whose code it describes (`CONTRIBUTING.md`, "Doc
| F12 | Entitlements panel | "No active plan", or the other ladders' lines only | The plan's line with a "Payment due" or "Paused" badge |
| F13 | Dashboard, an organization whose only plan grants on/off capabilities | The empty state, which says the organization holds no plan | Not empty |
| F14 | The member pays on Stripe's page | No way to pay | Pay invoice on the card and on an open invoice's detail (D8) |
| F15 | Pay invoice for another organization's invoice or plan | n/a | 404, as the invoice detail answers |
| F15 | Pay invoice for another organization's invoice or plan | n/a | 404: the styled error page (error-pages); the invoice detail, a partial, answers its plain 404 |
| F16 | Pay invoice, mapping recorded in the other environment or stale | n/a | 303 to `/billing`, no Stripe call; the invoice detail shows no control |
| F17 | Pay invoice, Stripe read fails or returns no URL | n/a | 303 to `/billing`; the log line carries no URL |
| F18 | Pay invoice after the plan recovered | n/a | By plan: the position is active again, 303 to `/billing` with no Stripe call (and 303 to `/billing` for a suspended position whose latest invoice is paid). By invoice: 303 to the paid invoice's hosted page |
@@ -205,7 +205,7 @@ Each doc moves with the section whose code it describes (`CONTRIBUTING.md`, "Doc
| F28 | Tests that hand-build position rows without a status | n/a | Kept by `ExcludeSuspended` while it exists, as a defensive default; no test feeds a filtered reader such a row today (D13) |
| F29 | A second Checkout while the first subscription is `incomplete` | Allowed: no position exists yet | Unchanged; note only (proposal). The same holds for an earlier Checkout Session that has not expired: it can still complete a second subscription on a suspended plan's ladder, because the console sets no `expires_at` (`billing.go:137-153`); this holds for active plans too |
| F30 | A payment-due plan whose scheduled downgrade Stripe already applied: Keep plan, and the page | Keep: `ErrNoActivePaidSubscription`. Page: not held | Keep: refused with `ErrScheduledMoveApplied`, copy row 17, nothing released, nothing superseded. Page: no "moves to" line and no Keep plan; Cancel plan stays (D6, D7). Cancel plan on it releases the schedule that holds the lower price and supersedes the `plan_switch` row that Stripe applied (`plan_change.go:582-586`, `:622-625`), and its body names the plan the card holds ending while Stripe bills the lower plan; the case needs no reconcile in the hour after the renewal (D6) and is accepted |
| F31 | An add-on whose subscription is suspended | The add-ons page reads no holdings: it offers Add again and shows no state | Unchanged (D14); on recovery both subscriptions deliver and bill; candidate issue (proposal) |
| F31 | An add-on whose subscription is suspended | The add-ons page reads no holdings: it offers Add again and shows no state | Unchanged (D14); on recovery both subscriptions deliver and bill; #258 |
| F32 | A paused plan: Keep plan, Cancel plan, a held rung's or free rung's Downgrade | `ErrNoActivePaidSubscription` | Not drawn; a posted request is refused with copy row 8 before any Stripe call (D6, D9) |
## Copy
@@ -244,7 +244,7 @@ Every sentence a person reads that this change adds or changes. The UI copy rule
| 14 | Billing card, a single subscription in `past_due`, `unpaid` or `paused` | no note, in place of `renews <date>` |
| 23 | Billing card, the Subscriptions row's badge when the worst state is `unpaid`, and the operator subscriptions list's status badge for an unpaid subscription | the existing label `Unpaid` in the danger tone, through its own badge key, in place of the warning tone |
Reused unchanged: Checkout's refusal (`That plan is no longer available. Refresh the page and choose a current plan.`), the Issue grant warning (`issueGrantSupersedeWarning`), the cancellation's success banner (`Your cancellation is scheduled.`), the current card's `Current plan` badge, and the existing state badges `Past due` and `Paused` on the Billing card.
Reused unchanged: Checkout's refusal (`That plan is no longer available. Refresh the page and choose a current plan.`), the Issue grant warning (`issueGrantSupersedeWarning`), the cancellation's success banner (`Your cancellation is scheduled.`), the current card's `Current plan` badge, the existing state badges `Past due` and `Paused` on the Billing card, and the Pay invoice routes' 404 page (`The page you requested does not exist.`) and 500 page (`An unexpected error occurred. Try again.`).
### Log lines
@@ -276,19 +276,20 @@ The local sources are the stripe-go v81.4.0 doc comments (API version `2025-02-2
| S2 | `cancel_at_period_end=true` is accepted on a `past_due` subscription | Cancel plan on the suspended page; Stripe accepts, the card reads "ends <date>" with the fact line |
| S3 | Clearing `cancel_at_period_end` is accepted on a `past_due` subscription | Keep plan after S2; the card reads "payment due" again |
| S4 | Paying the open invoice on the hosted page returns the subscription to `active` | Pay invoice, pay with `4242 4242 4242 4242`; the subscription reads `active`, the reconcile resumes the provision, the card renews and the entitlements return |
| S5 | Paying on the hosted page saves the new card as the subscription's default | Read the subscription's `default_payment_method` after S4; the result decides whether the card-change candidate issue is needed |
| S5 | Paying on the hosted page saves the new card as the subscription's default | Read the subscription's `default_payment_method` after S4; the result goes on #256 |
| S6 | The hosted page of an `unpaid` subscription's invoice takes payment, and paying the latest invoice reactivates it | Not settled: `unpaid` needs the retries to run out. The by-plan route falls back to `/billing`, whose per-invoice links pay each open invoice |
| S7 | `hosted_invoice_url` after the URL's expiry (30 days after the due date, at most 120 days) | Not settled by a sandbox run. A route that receives a URL answers with it; an expired page is Stripe's to explain |
| S8 | Releasing a schedule that the console attached while the subscription was active, once the subscription is `past_due`, and what Keep plan does to one whose downgrade has landed | Before S1, press Downgrade on a lower priced tier through the console, which attaches the console's schedule with its metadata and records the `plan_switch` row; a schedule made with the CLI carries no row and not the metadata (the console sets it in a second call, `plan_change.go:356`, `:395-396`), so Keep plan would have nothing to act on. Run S1. If Stripe refuses the billing-cycle-anchor update on a subscription that a schedule manages, record S8 as not settled and release the schedule from the CLI so that the rest of S1 continues. Otherwise, still past due and before S2, press Keep plan and record Stripe's answer to the release and that the card reads "payment due" again. The landed half cannot be reached without a test clock, so it is recorded as not settled and rests on `TestKeepRefusesADowngradeThatLanded` |
| S9 | When a period-end cancellation of a `past_due` subscription takes effect, Stripe stops automatic collection of all the customer's finalized invoices, including another subscription's open invoice (`subscription/client.go:114`) | Not settled by the plain sandbox run, which cannot reach the period end. Settled only with a test-clock customer: two subscriptions, one `past_due` and cancelled at the period end, the clock advanced past the period end, and the other's open invoice read for `auto_advance` and its next payment attempt. Unsettled, the design relies on the documentation and the candidate issue stands |
| S10 | The hosted page of an `uncollectible` invoice still takes payment; the console keeps such an invoice `open` (`webhook_invoice.go:24-27`), so its detail offers Pay invoice | Not settled by the plain sandbox run. Settled only with a test-clock customer whose account settings mark an invoice uncollectible when the retries run out, then opening that invoice's `hosted_invoice_url`, read with the CLI, because the console never maps a test-clock customer and so shows no Pay invoice for it. Unsettled, a member may reach a page that does not take payment |
| S11 | An abandoned upgrade's open invoice after `pending_update.expires_at`: `upgradeNow` voids it only while the pending update exists (`plan_change.go:300`), so if Stripe leaves it `open`, the detail's Pay invoice charges a proration for a move Stripe no longer applies | Not settled by the plain sandbox run. Settled only with a test-clock customer, through the CLI, because the console never maps one (S10): set `4000 0027 6000 3184` as the subscription's default payment method, update it to a higher price with `--payment-behavior pending_if_incomplete --proration-behavior always_invoice`, leave the invoice unpaid, advance the clock past `pending_update.expires_at`, and read the invoice's status. Unsettled, a member may pay that proration; if the invoice stays `open`, that is a candidate issue: void it in the sweep, or withhold `PayPath` for it |
A subscription schedule made from a `past_due` subscription, and `pending_if_incomplete` on one, are not exercised: D3 refuses the switch before either. Keep plan and Cancel plan do release a schedule that the console attached while the subscription was active, which S8 exercises. Keeping or cancelling a `paused` subscription is not exercised: fulfillment refuses both (D6, D9).
## Risks / Trade-offs
- **A member cannot move to a cheaper plan until they pay.** A member who cannot afford the current plan can cancel at the period end, or pay and then downgrade. Allowing a downgrade at renewal while past due needs a schedule from a past-due subscription, which is unverified.
- **The hosted page may not keep the new card.** If S5 shows it does not, a member who pays with a new card can fail again at the next renewal; the candidate issue for changing the card covers it.
- **The hosted page may not keep the new card.** If S5 shows it does not, a member who pays with a new card can fail again at the next renewal; #256 covers it.
- **A payment-due card can name a plan the subscription has already left.** A downgrade that landed is converged by the renewal's reconcile while the subscription is still active, so it stays unconverged only when no reconcile ran in the hour before Stripe charged the invoice (D6, D14). Then the card names the plan the position holds and reads the price line from the subscription. The card draws no "moves to" line and no Keep plan for it, and Keep plan refuses it, so the member cannot act on the difference; paying converges the plan. Cancel plan stays on it, and releases the schedule that holds the lower price and supersedes the row Stripe applied, with a body that names the plan the card holds ending while Stripe bills the lower one (F30); the case is rare and accepted.
- **A period-end cancellation of a payment-due plan can stop collection of the customer's other open invoices.** Stripe may stop automatic collection when the cancellation takes effect (S9). The member pays through Pay invoice, which does not depend on automatic collection.
- **The card's Pay invoice can lead to the Billing page.** When the plan's latest invoice is not the open one, or the read fails, the new tab shows `/billing`, where the open invoice's own link pays it.
@@ -71,12 +71,12 @@ None.
## Out of scope
Each is a candidate for the maintainer to decide on; none is filed.
Each is filed as its own issue, noted on an existing one, left to a landing check, or a note only.
- **A grace period for `past_due`.** The design documents describe a `past_due` subscription that keeps delivering for a configurable grace period; the code suspends at once, and the specs and the model card follow the code. This change keeps the code's behaviour. Candidate issue.
- **Changing the card on file.** A member cannot replace the card that fails; paying on Stripe's page may not make the new card the subscription's default (unverified until the landing check). Stripe's customer portal has a `payment_method_update` flow that could serve it. Candidate issue.
- **`invoice.marked_uncollectible` is not handled.** An invoice Stripe marks uncollectible keeps reading Open until another of its events arrives. The Pay invoice routes read Stripe when opened and do not depend on it. Candidate comment on #150.
- **A grace period for `past_due`.** The design documents describe a `past_due` subscription that keeps delivering for a configurable grace period; the code suspends at once, and the specs and the model card follow the code. This change keeps the code's behaviour. #255.
- **Changing the card on file.** A member cannot replace the card that fails; paying on Stripe's page may not make the new card the subscription's default (unverified until the landing check). Stripe's customer portal has a `payment_method_update` flow that could serve it. #256.
- **`invoice.marked_uncollectible` is not handled.** An invoice Stripe marks uncollectible keeps reading Open until the invoice sweep re-reads it, up to about a day later. The Pay invoice routes read Stripe when opened and do not depend on it. Noted on #150.
- **A second Checkout while the first subscription is `incomplete`.** An incomplete subscription has no position yet, so Checkout on its ladder stays allowed; Stripe expires an incomplete subscription after 23 hours. Note only.
- **An earlier Checkout Session that has not expired** can still complete a second subscription on a suspended plan's ladder. The console sets no `expires_at` on a session (`internal/server/billing.go:137-153`), so this holds for active plans too. Note only.
- **An add-on whose subscription is suspended.** The add-ons page reads no holdings, offers Add again and shows no state; on recovery both subscriptions deliver and bill. Candidate issue.
- **Collection stops when a period-end cancellation takes effect.** When the cancellation of a `past_due` plan takes effect, Stripe may stop automatic collection of all the customer's finalized invoices, which reaches the customer's other subscriptions; the member pays through Pay invoice. Candidate issue (design, S9).
- **An earlier Checkout Session that has not expired** can still complete a second subscription on a suspended plan's ladder. The console sets no `expires_at` on a session (`internal/server/billing.go:137-153`), so this holds for active plans too. #257.
- **An add-on whose subscription is suspended.** The add-ons page reads no holdings, offers Add again and shows no state; on recovery both subscriptions deliver and bill. #258.
- **Collection stops when a period-end cancellation takes effect.** When the cancellation of a `past_due` plan takes effect, Stripe may stop automatic collection of all the customer's finalized invoices, which reaches the customer's other subscriptions; the member pays through Pay invoice. Filed only if S9 confirms it.
@@ -110,15 +110,15 @@ Sections 1 to 6 are one commit each, and their items are that commit's parts; se
## 4. Pay invoice
- [ ] 4.1 `internal/server/member_hosted_invoice.go` (new, design D8), methods on `MemberInvoicesHandler`, which gains `EntitlementsQ` (and `MemberInvoicesConfig` with it): `memberInvoice(ctx, orgID, invoiceID)` is the gate `GetInvoiceDetail` applies today (`member_invoices.go:293-312`), and `GetInvoiceDetail` calls it, its answers unchanged; `GetHostedInvoice` answers `GET /billing/invoices/{invoiceID}/hosted` and `GetPlanUnpaidInvoice` answers `GET /billing/plans/{ladderID}/unpaid-invoice`, each exactly as design D8 says (the by-plan route picks its row through `entitlements.PositionsByLadder`, and after the gate answers 303 to `/billing` with no Stripe call for a row whose `position_status` is not `suspended`, for no Stripe querier, for no mapping row for the subscription, and for a mapping the key cannot reach), with `Cache-Control: no-store` set by the handler on every answer, though the middleware sets it on every response too (design D8), the Stripe read through `invoice.Get` and `subscription.Get` with `expand[]=latest_invoice`, the environment check through `MappingReachable` under `StripeMode`, and copy row 15 at Warn for a failed read. Neither writes the URL to a log line, and their doc comments say why (the URL is a bearer link).
- [ ] 4.2 `internal/server/server.go`: the two routes are registered beside `GET /billing`, behind the same sign-in requirement; the handler's configuration carries `cfg.EntitlementsQ`.
- [ ] 4.3 `member_plans.html` and `member_plans.go` (design D7, D8): on the current card whose `Suspension` is `payment_due`, the first control, drawn only when `LadderID` is set (`setCurrent` sets it after its early return for an empty price text, `member_plans.go:437-440`, so a card whose price is not projected would otherwise link to `/billing/plans//unpaid-invoice`), is `<a href="/billing/plans/{{ .LadderID }}/unpaid-invoice" target="_blank" rel="noopener" hx-boost="false" class="btn btn-outline-primary btn-sm w-100 mt-auto">Pay invoice</a>` (copy row 5), carrying `aria-describedby` for the billing line when it is shown; Keep plan and Cancel plan follow it with `mt-2`. `plansOfferPaidMove` (`member_plans.go:543-552`) counts a current tier whose `Suspension` is `payment_due`, and its doc comment says why.
- [ ] 4.4 The invoice detail: `MemberInvoiceDetailData` gains `PayPath`, set to `/billing/invoices/{id}/hosted` when the invoice's status is `open` and the mapping read at `member_invoices.go:331-335` exists and is reachable under `StripeMode`; `member_invoice_detail.html` renders it under the amounts as the link of copy row 11 with the same `target`, `rel`, `hx-boost` and classes.
- [ ] 4.5 `test/e2e/screens/manifest.go`: a Skip entry for each route, reason `redirects to Stripe's hosted invoice page, or to /billing; not a page`, so `screen-coverage` passes.
- [ ] 4.6 `docs/stripe.md`, "Plan changes": one sentence that Pay invoice opens Stripe's hosted invoice page, which the console reads when the member opens the link and never stores, and that it needs no key permission the console does not already use (it reads subscriptions and invoices).
- [ ] 4.7 Tests. `TestHostedInvoiceRoutes` (`internal/server`, a Stripe-mocked database test, built through the kit: an organization with a billing account, a subscription mapped with `MapSubscriptionIn`, invoices mapped with `MapInvoiceIn`, the mock's `Invoices` holding each Stripe invoice with its `status` and a `hosted_invoice_url` unique to the test, and the mock's subscription carrying its `latest_invoice`), one row per request through a named function, signed in through `authtest`: by invoice, an open invoice answers 303 with `Location` its URL and `Cache-Control: no-store`; a paid invoice answers 303 to its URL; another organization's invoice and an unknown id answer 404 with no Stripe call; an invoice mapped in the other environment and an unmapped one answer 303 to `/billing` with no Stripe call; an invoice the mock answers with an error (`ObjectErrs`) answers 303 to `/billing` and logs copy row 15; an invoice with no URL answers 303 to `/billing`. By plan, for an organization whose subscription is suspended `past_due`: an open latest invoice answers 303 to its URL; a paid latest invoice answers 303 to `/billing`; a ladder on which the organization holds no subscription-backed position, and an id that is not a UUID, answer 404 with no Stripe call; a subscription with no mapping row and a subscription mapped in the other environment each answer 303 to `/billing` with no Stripe call; a failed read answers 303 to `/billing`; a position whose status is `active` (the plan recovered, or an upgrade awaiting approval) answers 303 to `/billing` with no Stripe call. The open-latest-invoice row also checks that the recorded `GET /v1/subscriptions/{id}` call carries the expand of `latest_invoice`, in the form the mock records the call, because the mock answers the whole subscription for any path and live Stripe would return the bare invoice id without a URL (a failure reads `the subscription read carried no expand of latest_invoice`). The tests call the handlers directly through `authtest`, not through the middleware stack, so that removing a handler's own `Cache-Control` line is caught. Every row checks that no captured log record carries the URL, in its message or any attribute. `TestInvoiceDetailOffersPayInvoice` (a database test), one row per invoice: open and mapped in the key's environment shows the link of copy row 11 to its hosted route; paid, open and mapped in the other environment, and open and unmapped show none. `TestMemberPlansGolden`'s HTML goldens `paid-pro-past-due`, `paid-pro-past-due-held-plus-below`, `paid-pro-unpaid`, `paid-pro-past-due-no-default`, `paid-pro-past-due-cancellation-scheduled`, `paid-pro-past-due-downgrade-scheduled` and `paid-pro-past-due-downgrade-landed` change on purpose (the Pay invoice link first); `paid-pro-paused`, `unprojected-subscription-past-due` (its card has no price, so no `LadderID`, and draws no Pay invoice) and every other state do not, nor does any JSON golden. The billing line: `TestPlansOfferPaidMoveGate` (`billing_facts_render_test.go`) gains a row, a current tier whose `Suspension` is `payment_due`, failing with `t.Error("a plan whose payment is due does not earn the line")`; the check that Pay invoice references the paragraph is a separate test, `TestPayInvoiceReferencesTheBillingLine`, failing with `t.Error("Pay invoice does not reference the billing paragraph")`, so that `TestPlansCarryTheBillingLineOnce…`, which counts exactly two references (`billing_facts_render_test.go:16-38`), keeps its fixtures and its count. The route tables change on purpose: `routes.golden` and `routes-not-ready.golden` gain a row per route (anonymous: 302 to `/login`), and `route_pages.golden` a row per route (the member's probe with the zero UUID answers 404); the commit message lists the rows.
- [x] 4.1 `internal/server/member_hosted_invoice.go` (new, design D8), methods on `MemberInvoicesHandler`, which gains `EntitlementsQ` (and `MemberInvoicesConfig` with it): `memberInvoice(ctx, orgID, invoiceID)` is the gate `GetInvoiceDetail` applies today (`member_invoices.go:293-312`), and `GetInvoiceDetail` calls it, its answers unchanged; `GetHostedInvoice` answers `GET /billing/invoices/{invoiceID}/hosted` and `GetPlanUnpaidInvoice` answers `GET /billing/plans/{ladderID}/unpaid-invoice`, each exactly as design D8 says (the by-plan route picks its row through `entitlements.PositionsByLadder`, and after the gate answers 303 to `/billing` with no Stripe call for a row whose `position_status` is not `suspended`, for no Stripe querier, for no mapping row for the subscription, and for a mapping the key cannot reach), with `Cache-Control: no-store` set by the handler on every answer, though the middleware sets it on every response too (design D8), the Stripe read through `invoice.Get` and `subscription.Get` with `expand[]=latest_invoice`, the environment check through `MappingReachable` under `StripeMode`, and copy row 15 at Warn for a failed read. Neither writes the URL to a log line, and their doc comments say why (the URL is a bearer link).
- [x] 4.2 `internal/server/server.go`: the two routes are registered beside `GET /billing`, behind the same sign-in requirement; the handler's configuration carries `cfg.EntitlementsQ`.
- [x] 4.3 `member_plans.html` and `member_plans.go` (design D7, D8): on the current card whose `Suspension` is `payment_due`, the first control, drawn only when `LadderID` is set (`setCurrent` sets it after its early return for an empty price text, `member_plans.go:437-440`, so a card whose price is not projected would otherwise link to `/billing/plans//unpaid-invoice`), is `<a href="/billing/plans/{{ .LadderID }}/unpaid-invoice" target="_blank" rel="noopener" hx-boost="false" class="btn btn-outline-primary btn-sm w-100 mt-auto">Pay invoice</a>` (copy row 5), carrying `aria-describedby` for the billing line when it is shown; Keep plan and Cancel plan follow it with `mt-2`. `plansOfferPaidMove` (`member_plans.go:543-552`) counts a current tier whose `Suspension` is `payment_due`, and its doc comment says why.
- [x] 4.4 The invoice detail: `MemberInvoiceDetailData` gains `PayPath`, set to `/billing/invoices/{id}/hosted` when the invoice's status is `open` and the mapping read at `member_invoices.go:331-335` exists and is reachable under `StripeMode`; `member_invoice_detail.html` renders it under the amounts as the link of copy row 11 with the same `target`, `rel`, `hx-boost` and classes.
- [x] 4.5 `test/e2e/screens/manifest.go`: a Skip entry for each route, reason `redirects to Stripe's hosted invoice page, or to /billing; not a page`, so `screen-coverage` passes.
- [x] 4.6 `docs/stripe.md`, "Plan changes": one sentence that Pay invoice opens Stripe's hosted invoice page, which the console reads when the member opens the link and never stores, and that it needs no key permission the console does not already use (it reads subscriptions and invoices).
- [x] 4.7 Tests. `TestHostedInvoiceRoutes` (`internal/server`, a Stripe-mocked database test, built through the kit: an organization with a billing account, a subscription mapped with `MapSubscriptionIn`, invoices mapped with `MapInvoiceIn`, the mock's `Invoices` holding each Stripe invoice with its `status` and a `hosted_invoice_url` unique to the test, and the mock's subscription carrying its `latest_invoice`), one row per request through a named function, signed in through `authtest`: by invoice, an open invoice answers 303 with `Location` its URL and `Cache-Control: no-store`; a paid invoice answers 303 to its URL; another organization's invoice and an unknown id answer 404 with no Stripe call; an invoice mapped in the other environment and an unmapped one answer 303 to `/billing` with no Stripe call; an invoice the mock answers with an error (`ObjectErrs`) answers 303 to `/billing` and logs copy row 15; an invoice with no URL answers 303 to `/billing`. By plan, for an organization whose subscription is suspended `past_due`: an open latest invoice answers 303 to its URL; a paid latest invoice answers 303 to `/billing`; a ladder on which the organization holds no subscription-backed position, and an id that is not a UUID, answer 404 with no Stripe call; a subscription with no mapping row and a subscription mapped in the other environment each answer 303 to `/billing` with no Stripe call; a failed read answers 303 to `/billing`; a position whose status is `active` (the plan recovered, or an upgrade awaiting approval) answers 303 to `/billing` with no Stripe call. The open-latest-invoice row also checks that the recorded `GET /v1/subscriptions/{id}` call carries the expand of `latest_invoice`, in the form the mock records the call, because the mock answers the whole subscription for any path and live Stripe would return the bare invoice id without a URL (a failure reads `the subscription read carried no expand of latest_invoice`). The tests call the handlers directly through `authtest`, not through the middleware stack, so that removing a handler's own `Cache-Control` line is caught. Every row checks that no captured log record carries the URL, in its message or any attribute. `TestInvoiceDetailOffersPayInvoice` (a database test), one row per invoice: open and mapped in the key's environment shows the link of copy row 11 to its hosted route; paid, open and mapped in the other environment, and open and unmapped show none. `TestMemberPlansGolden`'s HTML goldens `paid-pro-past-due`, `paid-pro-past-due-held-plus-below`, `paid-pro-unpaid`, `paid-pro-past-due-no-default`, `paid-pro-past-due-cancellation-scheduled`, `paid-pro-past-due-downgrade-scheduled` and `paid-pro-past-due-downgrade-landed` change on purpose (the Pay invoice link first); `paid-pro-paused`, `unprojected-subscription-past-due` (its card has no price, so no `LadderID`, and draws no Pay invoice) and every other state do not, nor does any JSON golden. The billing line: `TestPlansOfferPaidMoveGate` (`billing_facts_render_test.go`) gains a row, a current tier whose `Suspension` is `payment_due`, failing with `t.Error("a plan whose payment is due does not earn the line")`; the check that Pay invoice references the paragraph is a separate test, `TestPayInvoiceReferencesTheBillingLine`, failing with `t.Error("Pay invoice does not reference the billing paragraph")`, so that `TestPlansCarryTheBillingLineOnce…`, which counts exactly two references (`billing_facts_render_test.go:16-38`), keeps its fixtures and its count. The route tables change on purpose: `routes.golden` and `routes-not-ready.golden` gain a row per route (anonymous: 302 to `/login`), and `route_pages.golden` a row per route (the member's probe with the zero UUID answers 404); the commit message lists the rows.
- [ ] 4.8 `make screens`: no changed screen expected; the invoice detail is a partial no screen captures, and the demo seed's invoices carry no Stripe mapping.
- [ ] 4.9 Breaks:
- [x] 4.9 Breaks:
- the by-invoice route without the ownership gate: caught by `TestHostedInvoiceRoutes`'s other-organization row, `want_new` `status = 303, want 404`;
- `GetInvoiceDetail` and the route calling one gate function is a fact of the code's structure that no request can tell from two copies answering alike; it is read in review, the commit message says so, and no break is listed;
- the environment check removed from the by-invoice route: caught by its other-environment row, `want_new` `the route called Stripe for an invoice the key cannot reach`;
+2
View File
@@ -120,6 +120,8 @@ var Manifest = []Screen{
{Route: "/domains", Skip: "redirects to the dashboard's domains section (dissolve-member-domains)"},
{Route: "/domains/ask", Skip: "the reverse proxy's on-demand TLS authorization endpoint (domain-authorization); answers 200/4xx to a ?domain= query, not a page"},
{Route: "/protected", Skip: "session probe used by the auth walkthroughs, not a page"},
{Route: "/billing/invoices/{invoiceID}/hosted", Skip: "redirects to Stripe's hosted invoice page, or to /billing; not a page"},
{Route: "/billing/plans/{ladderID}/unpaid-invoice", Skip: "redirects to Stripe's hosted invoice page, or to /billing; not a page"},
{Route: "/operator/", Skip: "catch-all under /operator/ that renders the operator not-found page"},
// operator.lookup.search became a native GET (forms-library lane D,
// design D4's Search kind): with no query it renders the same landing