Show the period an invoice's lines pay for

#293. The member's Billing page, the member's invoice page and the
operator's invoice page showed the period Stripe sets on the invoice
itself, which Stripe defines as the span in which the invoice's items
were added: a purchase's began and ended on the day of the purchase, and
a renewal's was the month that had just ended. Each stored line carries
the service period it bills, so an invoice's period now runs from the
earliest start to the latest end of its stored lines whose end is after
their start and whose amount is above zero; when no line is above zero,
lines of zero count instead, so a free price's invoice still shows its
period. A credit (the unused time an upgrade returns) and a zero line
beside a charge (the credit of an upgrade from a $0 price) are left
out. When no line counts, as on a one-off invoice, the period reads as
unavailable.

The period is computed when a page is read (invoicePeriodFromLines):
the Billing list reads its invoices' lines in one statement
(GetInvoiceLineItemsByInvoiceIDs), and each invoice page from the lines
it already reads. A failed read of lines shows the page's load error on
the list and the unavailable marker on an invoice page; no period is
derived from a failed read. No migration: stored rows keep Stripe's
invoice period, which the reconcile still writes, and the pages stop
reading it. The demo seed now writes Stripe's invoice-level period on
each demo invoice row, as Stripe would, and keeps the service period on
its line, so the pages show the line's period whatever the row holds
(four demo seed goldens change in the invoice rows' period columns), and
docs/stripe.md and docs/models/payments-billing.md say what the pages
show. No string is added.

Tests: TestInvoicePeriodFromLines (every case of the design's table,
among them a trial beside a one-off fee, which reads as unavailable),
TestInvoicePagesShowThePeriodTheirLinesPayFor,
TestInvoicePagesShowNoPeriodWhenTheirLinesCannotBeRead,
TestMemberInvoiceListShowsTheLoadErrorWhenItsLinesCannotBeRead and
TestMemberInvoiceListReadsItsLinesInOneStatement.

Full suite on reset databases, on this commit's tree: 5379 passed, 16
skipped, none failed. make screens: no capture this commit touches
changed (the demo invoices read the same periods); the three operator
captures that differ show ids from the demo snapshot's rebuild and a
day count (#120), unchanged by this commit.

Breaks, all CAUGHT (28):
- credits counted (an upgrade to a monthly price)
- credits counted (credits only)
- zero-amount lines left out (a trial)
- zero-amount lines left out (a free price)
- a line of zero counted beside a charge (an upgrade from a free yearly
  price)
- a line of zero never counted (a free price)
- a line of zero never counted (a trial)
- a line of zero taken for a charge (a free price)
- a line of zero taken for a charge (a trial)
- the first start lost (one subscription line)
- a period of one moment counted (a one-off item)
- a period of one moment counted (a one-off item beside a renewal)
- a line with no start counted (a line with no start)
- the latest start kept (prorations folded into a renewal)
- the earliest end kept (prorations folded into a renewal)
- the list reading the invoice row (member list)
- the list filing every line under every listed invoice (member list)
- the member's invoice page reading the invoice row
- the operator's invoice page reading the invoice row
- the list ignoring a failed read of lines
- the member's invoice page showing no marker after a failed read
- the operator's invoice page showing no marker after a failed read
- the operator's line rows showing the invoice row's period
- the demo seed writing the line's period on the invoice row again
- the list falling back to the invoice row when no line counts
- the member's invoice page falling back to the invoice row when no
  line counts
- the operator's invoice page falling back to the invoice row when no
  line counts
- the list reading each invoice's lines in its own statement

Two edits have no break, because each is equivalent: dropping the
end's NULL check (a NULL end reads as the zero time, never after a set
start) and dropping `!end.Valid ||` from the latest-end condition (with
no end set, every set end is after the zero time).
This commit is contained in:
2026-10-30 06:46:12 -05:00
parent 0679966f5b
commit c7741fa59f
21 changed files with 1155 additions and 28 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ Database objects sit in the `core` schema (defined in `internal/db/migrations/00
| `core.subscription_items` | The per-product, per-price lines of a subscription, with quantity. `ended_at` is set by the reconciler when the line's Stripe item is no longer on the subscription (a schedule's phase change replaces the item); the row stays because invoice lines reference it, and every reader of a subscription's current lines skips it | `00001_init.sql`; `00021_subscription_item_ended.sql` |
| `core.subscription_changes` | Append-only history of status transitions (`previous_status` → `new_status`), each carrying an attribution string saying what triggered it | `00001_init.sql` |
| `core.subscription_scheduled_changes` | The intent ledger: future plan switches, quantity changes, and cancellations, with when they fire and their lifecycle status | `00001_init.sql` |
| `core.invoices`, `core.invoice_line_items`, `core.payments`, `core.payment_methods` | The money projections: amounts in integer minor units (cents) with a per-row currency; payment methods store safe card descriptors (brand, last4, expiry), never card data. `core.invoices.invoice_number` [db] is the platform's own reference number (upstream data model v16.2; identifier policy arbitration 2026-08-29): a zero-padded decimal of at least four digits (e.g. `0042`), assigned per billing account from `core.accounts.next_invoice_number` — a transactional, gapless counter, never a Postgres sequence — in the same transaction that projects the invoice; unique per billing account where present, immutable once assigned, required for every non-draft row (`chk_invoices_issued_have_number`). Rows numbered before this scheme (v16.2) keep the Stripe-format string they were quoted with verbatim, so one account may show both forms; Stripe's own customer-facing number moves to `stripe.invoice_mappings.stripe_invoice_number` as an external reference, shown on the invoice detail as "Stripe invoice ..." and never treated as the invoice's identity. `created_at` on these rows is when the console wrote them, set by the database and never backdated; the times Stripe reports have columns of their own: an invoice's `issued_at` (Stripe's date of issue, or its finalization; required for every non-draft row, `chk_invoices_issued_have_issued_at`), `paid_at` and `voided_at`, and a payment's `paid_at` and `failed_at`. Every invoice list sorts by `issued_at` and every payment list by `paid_at`, else `failed_at`, newest first, with the id breaking ties | `00001_init.sql`; `00013_invoice_numbers.sql`; `00022_invoice_issued_payment_paid.sql`; `internal/integrations/stripe/store/migrations/00003_invoice_number.sql` |
| `core.invoices`, `core.invoice_line_items`, `core.payments`, `core.payment_methods` | The money projections: amounts in integer minor units (cents) with a per-row currency; payment methods store safe card descriptors (brand, last4, expiry), never card data. `core.invoices.invoice_number` [db] is the platform's own reference number (upstream data model v16.2; identifier policy arbitration 2026-08-29): a zero-padded decimal of at least four digits (e.g. `0042`), assigned per billing account from `core.accounts.next_invoice_number` — a transactional, gapless counter, never a Postgres sequence — in the same transaction that projects the invoice; unique per billing account where present, immutable once assigned, required for every non-draft row (`chk_invoices_issued_have_number`). Rows numbered before this scheme (v16.2) keep the Stripe-format string they were quoted with verbatim, so one account may show both forms; Stripe's own customer-facing number moves to `stripe.invoice_mappings.stripe_invoice_number` as an external reference, shown on the invoice detail as "Stripe invoice ..." and never treated as the invoice's identity. `created_at` on these rows is when the console wrote them, set by the database and never backdated; the times Stripe reports have columns of their own: an invoice's `issued_at` (Stripe's date of issue, or its finalization; required for every non-draft row, `chk_invoices_issued_have_issued_at`), `paid_at` and `voided_at`, and a payment's `paid_at` and `failed_at`. Every invoice list sorts by `issued_at` and every payment list by `paid_at`, else `failed_at`, newest first, with the id breaking ties. An invoice's `period_start` and `period_end` hold Stripe's invoice-level period, which looks back one period on a subscription invoice; every page shows an invoice's period from its lines instead ([stripe.md](../stripe.md#invoices-and-payments)). | `00001_init.sql`; `00013_invoice_numbers.sql`; `00022_invoice_issued_payment_paid.sql`; `internal/integrations/stripe/store/migrations/00003_invoice_number.sql` |
| `core.webhook_events` | The inbound event store all integrations share (Discourse uses it too): every verified Stripe event lands here before processing; partitioned by month, meaning each month's rows live in their own physical sub-table; partitions are created ahead of need at boot and by a recurring maintenance schedule | `00001_init.sql` |
| Partition maintenance | The idempotent ensure pass that creates upcoming monthly partitions, run once at boot and on a 24h Temporal schedule (`webhook-partition-ensure`) | `internal/db/migrations/00031_webhook_partition_function.sql`, `internal/db/partitions.go`, `internal/workflows/maintenance/` |
| `core.outbox` | The transactional outbox for outbound work: rows enqueue in the same database transaction as the domain write that needs them, and a drainer executes them against Stripe | `00001_init.sql` |
+1 -1
View File
@@ -126,7 +126,7 @@ Each invoice and payment carries two kinds of time. `created_at` is when the con
- A succeeded payment's `paid_at` is the invoice's paid time when its charge is the one the invoice names as having paid it, and otherwise the charge's creation time. The payment of an invoice paid with no charge takes the invoice's paid time.
- A failed payment's `failed_at` is the failed charge's creation time. A Stripe charge records when the attempt started and nothing later, so for a card, which is declined at once, this is when it failed. For a payment method that confirms later, such as a bank debit (ACH Direct Debit takes up to four business days), it is when the attempt started, days before Stripe reported the failure.
An invoice's period is Stripe's invoice-level `period_start` and `period_end`, the span in which its items were added. On a subscription invoice it looks back one period: a renewal's period is the one that just ended, not the one it charges for. Each line carries its own period, which is the service period it bills.
An invoice's period, on the Billing page, the member's invoice page and the operator's invoice page, is the period its lines pay for: from the earliest start to the latest end of its stored lines, leaving out credits (a negative amount, such as the unused time an upgrade returns), a line of zero beside a line above zero (such as the credit of an upgrade from a $0 price), and lines whose period is a single moment (an invoice item given no period of its own). An invoice whose lines are all zero, such as one for a free price or a trial, shows their period. An invoice with no such line shows no period. Each line carries the service period it bills: a subscription line the subscription's period, a proration the time from the change to the end of the period, an invoice item the moment it was created unless it was given a period. The console also stores Stripe's invoice-level `period_start` and `period_end`, the span in which the invoice's items were added, and shows it nowhere: on a subscription invoice it looks back one period, so a renewal's is the period that just ended and a purchase's begins and ends at the purchase.
Each invoice also carries two numbers. The console's own counts from 0001 within each billing account, so a member can count only their own invoices. Stripe's number is stored beside it and is the one printed on Stripe's invoice PDF, hosted page and receipt. It follows the numbering scheme on the **Invoice settings** page of the Stripe Dashboard. **Sequentially for each customer** gives every customer its own prefix and sequence. **Sequentially across your account** runs one sequence for every customer, so any member can read the account's invoice volume from their own invoice numbers. Stripe defaults accounts in the EU and the UK to the second, saying those countries typically require it; HMRC asks for "a sequential number based on one or more series" (VAT Notice 700 §16.3.1). Which scheme a deployment's jurisdiction accepts is a question for its accountant.
@@ -13,6 +13,7 @@ import (
"database/sql"
"github.com/google/uuid"
"github.com/lib/pq"
)
const countInvoiceLineItemsByInvoiceID = `-- name: CountInvoiceLineItemsByInvoiceID :one
@@ -124,3 +125,49 @@ func (q *Queries) GetInvoiceLineItemsByInvoiceID(ctx context.Context, invoiceID
}
return items, nil
}
const getInvoiceLineItemsByInvoiceIDs = `-- name: GetInvoiceLineItemsByInvoiceIDs :many
SELECT line_item_id, invoice_id, subscription_item_id, price_id, amount, currency, description, quantity, period_start, period_end, created_at, updated_at FROM core.invoice_line_items
WHERE invoice_id = ANY($1::uuid[])
ORDER BY invoice_id, created_at ASC, line_item_id ASC
`
// The lines of several invoices in one read, for the member's invoice
// list, which shows each invoice's period from its lines. Each invoice's
// lines come in insert order, as GetInvoiceLineItemsByInvoiceID returns
// them.
func (q *Queries) GetInvoiceLineItemsByInvoiceIDs(ctx context.Context, invoiceIds []string) ([]InvoiceLineItem, error) {
rows, err := q.db.QueryContext(ctx, getInvoiceLineItemsByInvoiceIDs, pq.Array(invoiceIds))
if err != nil {
return nil, err
}
defer rows.Close()
items := []InvoiceLineItem{}
for rows.Next() {
var i InvoiceLineItem
if err := rows.Scan(
&i.LineItemID,
&i.InvoiceID,
&i.SubscriptionItemID,
&i.PriceID,
&i.Amount,
&i.Currency,
&i.Description,
&i.Quantity,
&i.PeriodStart,
&i.PeriodEnd,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+5
View File
@@ -175,6 +175,11 @@ type Querier interface {
// Insert order, which is Stripe's line order. One transaction inserts every
// line of an invoice at one NOW(), so the id breaks the tie.
GetInvoiceLineItemsByInvoiceID(ctx context.Context, invoiceID string) ([]InvoiceLineItem, error)
// The lines of several invoices in one read, for the member's invoice
// list, which shows each invoice's period from its lines. Each invoice's
// lines come in insert order, as GetInvoiceLineItemsByInvoiceID returns
// them.
GetInvoiceLineItemsByInvoiceIDs(ctx context.Context, invoiceIds []string) ([]InvoiceLineItem, error)
// Newest issue first, a draft (no issue time) ahead of them all, the id
// breaking ties (stripe-invoice-reconcile D17): every invoice list orders
// this way, never by created_at, the time the console wrote the row.
@@ -14,6 +14,15 @@ SELECT * FROM core.invoice_line_items
WHERE invoice_id = $1
ORDER BY created_at ASC, line_item_id ASC;
-- name: GetInvoiceLineItemsByInvoiceIDs :many
-- The lines of several invoices in one read, for the member's invoice
-- list, which shows each invoice's period from its lines. Each invoice's
-- lines come in insert order, as GetInvoiceLineItemsByInvoiceID returns
-- them.
SELECT * FROM core.invoice_line_items
WHERE invoice_id = ANY(sqlc.arg(invoice_ids)::uuid[])
ORDER BY invoice_id, created_at ASC, line_item_id ASC;
-- name: CountInvoiceLineItemsByInvoiceID :one
-- The invoice reconcile inserts lines only into an invoice with none
-- (stripe-invoice-reconcile D5), and asks this under its lock.
+12 -7
View File
@@ -924,7 +924,7 @@ type demoInvoice struct {
// paid, with its payment; 0002 overdue (due date permanently past); 0003
// open (due date permanently future).
func issueDemoInvoices(ctx context.Context, billQ *billing.Queries, d demoInvoice) error {
paid, err := d.issue(ctx, billQ, demoDay(2025, time.November, 1), demoDay(2025, time.December, 1), demoDay(2025, time.December, 15))
paid, err := d.issue(ctx, billQ, demoDay(2025, time.November, 1), demoDay(2025, time.November, 1), demoDay(2025, time.December, 1), demoDay(2025, time.December, 15))
if err != nil {
return err
}
@@ -940,19 +940,24 @@ func issueDemoInvoices(ctx context.Context, billQ *billing.Queries, d demoInvoic
}); err != nil {
return fmt.Errorf("create payment: %w", err)
}
if _, err := d.issue(ctx, billQ, demoDay(2025, time.December, 1), demoDay(2026, time.January, 1), demoDay(2026, time.January, 15)); err != nil {
if _, err := d.issue(ctx, billQ, demoDay(2025, time.November, 1), demoDay(2025, time.December, 1), demoDay(2026, time.January, 1), demoDay(2026, time.January, 15)); err != nil {
return err
}
_, err = d.issue(ctx, billQ, demoDay(2026, time.January, 1), demoDay(2026, time.February, 1), demoDay(2030, time.January, 15))
_, err = d.issue(ctx, billQ, demoDay(2025, time.December, 1), demoDay(2026, time.January, 1), demoDay(2026, time.February, 1), demoDay(2030, time.January, 15))
return err
}
// issue writes one open invoice of the next number with its one line. Each
// invoice is issued at the start of its period, at noon UTC like every date
// here. The lists show and sort invoices by issue time and payments by when
// invoice is issued at the start of the period its line bills, at noon UTC like
// every date here. The lists show and sort invoices by issue time and payments by when
// they succeeded (stripe-invoice-reconcile D17), and test/pin-timestamps.sql
// pins only row clocks, so these are the dates the demo screens show.
func (d demoInvoice) issue(ctx context.Context, billQ *billing.Queries, periodStart, periodEnd, due sql.NullTime) (billing.Invoice, error) {
//
// The invoice row carries Stripe's invoice-level period, which runs from
// invoiceFrom to the line's start: the purchase's begins and ends at the
// purchase, and a renewal's looks back one period. Every page shows each
// invoice's period from its line.
func (d demoInvoice) issue(ctx context.Context, billQ *billing.Queries, invoiceFrom, periodStart, periodEnd, due sql.NullTime) (billing.Invoice, error) {
n, err := billQ.AssignNextInvoiceNumber(ctx, d.accountID)
if err != nil {
return billing.Invoice{}, fmt.Errorf("assign invoice number: %w", err)
@@ -961,7 +966,7 @@ func (d demoInvoice) issue(ctx context.Context, billQ *billing.Queries, periodSt
BillingAccountID: d.accountID,
SubscriptionID: uuid.NullUUID{UUID: uuid.MustParse(d.subscriptionID), Valid: true},
Status: "open", AmountDue: 1000, AmountPaid: 0, Currency: "usd",
PeriodStart: periodStart, PeriodEnd: periodEnd, DueDate: due,
PeriodStart: invoiceFrom, PeriodEnd: periodStart, DueDate: due,
InvoiceNumber: nullString(fmt.Sprintf("%04d", n)),
IssuedAt: periodStart,
})
+3 -3
View File
@@ -43,9 +43,9 @@ product | price | quantity
demo-plan | demo_monthly | 1
-- invoices
invoice_number | status | amount_due | amount_paid | currency | period_start | period_end | due_date | issued_at | paid_at
0001 | paid | 1000 | 1000 | usd | 2025-11-01T12:00:00Z | 2025-12-01T12:00:00Z | 2025-12-15T12:00:00Z | 2025-11-01T12:00:00Z | 2025-12-10T12:00:00Z
0002 | open | 1000 | 0 | usd | 2025-12-01T12:00:00Z | 2026-01-01T12:00:00Z | 2026-01-15T12:00:00Z | 2025-12-01T12:00:00Z | NULL
0003 | open | 1000 | 0 | usd | 2026-01-01T12:00:00Z | 2026-02-01T12:00:00Z | 2030-01-15T12:00:00Z | 2026-01-01T12:00:00Z | NULL
0001 | paid | 1000 | 1000 | usd | 2025-11-01T12:00:00Z | 2025-11-01T12:00:00Z | 2025-12-15T12:00:00Z | 2025-11-01T12:00:00Z | 2025-12-10T12:00:00Z
0002 | open | 1000 | 0 | usd | 2025-11-01T12:00:00Z | 2025-12-01T12:00:00Z | 2026-01-15T12:00:00Z | 2025-12-01T12:00:00Z | NULL
0003 | open | 1000 | 0 | usd | 2025-12-01T12:00:00Z | 2026-01-01T12:00:00Z | 2030-01-15T12:00:00Z | 2026-01-01T12:00:00Z | NULL
-- lines
invoice_number | description | amount | quantity | period_start | period_end
0001 | demo-plan (monthly) | 1000 | 1 | 2025-11-01T12:00:00Z | 2025-12-01T12:00:00Z
+3 -3
View File
@@ -42,9 +42,9 @@ product | price | quantity
demo-plan | demo_monthly | 1
-- invoices
invoice_number | status | amount_due | amount_paid | currency | period_start | period_end | due_date | issued_at | paid_at
0001 | paid | 1000 | 1000 | usd | 2025-11-01T12:00:00Z | 2025-12-01T12:00:00Z | 2025-12-15T12:00:00Z | 2025-11-01T12:00:00Z | 2025-12-10T12:00:00Z
0002 | open | 1000 | 0 | usd | 2025-12-01T12:00:00Z | 2026-01-01T12:00:00Z | 2026-01-15T12:00:00Z | 2025-12-01T12:00:00Z | NULL
0003 | open | 1000 | 0 | usd | 2026-01-01T12:00:00Z | 2026-02-01T12:00:00Z | 2030-01-15T12:00:00Z | 2026-01-01T12:00:00Z | NULL
0001 | paid | 1000 | 1000 | usd | 2025-11-01T12:00:00Z | 2025-11-01T12:00:00Z | 2025-12-15T12:00:00Z | 2025-11-01T12:00:00Z | 2025-12-10T12:00:00Z
0002 | open | 1000 | 0 | usd | 2025-11-01T12:00:00Z | 2025-12-01T12:00:00Z | 2026-01-15T12:00:00Z | 2025-12-01T12:00:00Z | NULL
0003 | open | 1000 | 0 | usd | 2025-12-01T12:00:00Z | 2026-01-01T12:00:00Z | 2030-01-15T12:00:00Z | 2026-01-01T12:00:00Z | NULL
-- lines
invoice_number | description | amount | quantity | period_start | period_end
0001 | demo-plan (monthly) | 1000 | 1 | 2025-11-01T12:00:00Z | 2025-12-01T12:00:00Z
+3 -3
View File
@@ -27,9 +27,9 @@ product | price | quantity
demo-plan | demo_monthly | 1
-- invoices
invoice_number | status | amount_due | amount_paid | currency | period_start | period_end | due_date | issued_at | paid_at
0001 | paid | 1000 | 1000 | usd | 2025-11-01T12:00:00Z | 2025-12-01T12:00:00Z | 2025-12-15T12:00:00Z | 2025-11-01T12:00:00Z | 2025-12-10T12:00:00Z
0002 | open | 1000 | 0 | usd | 2025-12-01T12:00:00Z | 2026-01-01T12:00:00Z | 2026-01-15T12:00:00Z | 2025-12-01T12:00:00Z | NULL
0003 | open | 1000 | 0 | usd | 2026-01-01T12:00:00Z | 2026-02-01T12:00:00Z | 2030-01-15T12:00:00Z | 2026-01-01T12:00:00Z | NULL
0001 | paid | 1000 | 1000 | usd | 2025-11-01T12:00:00Z | 2025-11-01T12:00:00Z | 2025-12-15T12:00:00Z | 2025-11-01T12:00:00Z | 2025-12-10T12:00:00Z
0002 | open | 1000 | 0 | usd | 2025-11-01T12:00:00Z | 2025-12-01T12:00:00Z | 2026-01-15T12:00:00Z | 2025-12-01T12:00:00Z | NULL
0003 | open | 1000 | 0 | usd | 2025-12-01T12:00:00Z | 2026-01-01T12:00:00Z | 2030-01-15T12:00:00Z | 2026-01-01T12:00:00Z | NULL
-- lines
invoice_number | description | amount | quantity | period_start | period_end
0001 | demo-plan (monthly) | 1000 | 1 | 2025-11-01T12:00:00Z | 2025-12-01T12:00:00Z
+3 -3
View File
@@ -42,9 +42,9 @@ demo-plan | demo_monthly | 1
demo-plan | demo_monthly | 1
-- invoices
invoice_number | status | amount_due | amount_paid | currency | period_start | period_end | due_date | issued_at | paid_at
0001 | paid | 1000 | 1000 | usd | 2025-11-01T12:00:00Z | 2025-12-01T12:00:00Z | 2025-12-15T12:00:00Z | 2025-11-01T12:00:00Z | 2025-12-10T12:00:00Z
0002 | open | 1000 | 0 | usd | 2025-12-01T12:00:00Z | 2026-01-01T12:00:00Z | 2026-01-15T12:00:00Z | 2025-12-01T12:00:00Z | NULL
0003 | open | 1000 | 0 | usd | 2026-01-01T12:00:00Z | 2026-02-01T12:00:00Z | 2030-01-15T12:00:00Z | 2026-01-01T12:00:00Z | NULL
0001 | paid | 1000 | 1000 | usd | 2025-11-01T12:00:00Z | 2025-11-01T12:00:00Z | 2025-12-15T12:00:00Z | 2025-11-01T12:00:00Z | 2025-12-10T12:00:00Z
0002 | open | 1000 | 0 | usd | 2025-11-01T12:00:00Z | 2025-12-01T12:00:00Z | 2026-01-15T12:00:00Z | 2025-12-01T12:00:00Z | NULL
0003 | open | 1000 | 0 | usd | 2025-12-01T12:00:00Z | 2026-01-01T12:00:00Z | 2030-01-15T12:00:00Z | 2026-01-01T12:00:00Z | NULL
-- lines
invoice_number | description | amount | quantity | period_start | period_end
0001 | demo-plan (monthly) | 1000 | 1 | 2025-11-01T12:00:00Z | 2025-12-01T12:00:00Z
@@ -521,6 +521,11 @@ func (r *invoiceReconcile) createInvoice(ctx context.Context, billingQ *billing.
return billing.Invoice{}, r.dbErr("assign invoice number", err)
}
st := r.transitions()
// PeriodStart and PeriodEnd are Stripe's invoice-level period: the span
// in which the invoice's items were added, which looks back one period
// on a subscription invoice. No page shows it; each page shows the
// period the invoice's lines pay for (invoicePeriodFromLines,
// internal/server/member_invoices.go).
inv, err := billingQ.CreateInvoice(ctx, billing.CreateInvoiceParams{
BillingAccountID: billingAccountID,
SubscriptionID: subLink,
+293
View File
@@ -0,0 +1,293 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
// The period every invoice page shows is the period the invoice's lines pay
// for, never Stripe's invoice-level period the invoice row holds (design D1,
// D2): the member's Billing page, the member's invoice page and the
// operator's invoice page, and the operator's line rows beside them.
import (
"context"
"io"
"log/slog"
"strings"
"testing"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"git.coopcloud.tech/wiki-cafe/member-console/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"
)
// periodLinesFailingQ fails the read of one invoice's lines, as a failed
// query returns: an empty answer and errReadFailed.
type periodLinesFailingQ struct {
billing.Querier
}
func (q periodLinesFailingQ) GetInvoiceLineItemsByInvoiceID(context.Context, string) ([]billing.InvoiceLineItem, error) {
var none []billing.InvoiceLineItem
return none, errReadFailed
}
// periodCountingQ counts the reads of invoice lines the Billing list makes:
// the batch read of every listed invoice's lines, and the read of one
// invoice's lines.
type periodCountingQ struct {
billing.Querier
batchReads, singleReads int
}
func (q *periodCountingQ) GetInvoiceLineItemsByInvoiceIDs(ctx context.Context, ids []string) ([]billing.InvoiceLineItem, error) {
q.batchReads++
return q.Querier.GetInvoiceLineItemsByInvoiceIDs(ctx, ids)
}
func (q *periodCountingQ) GetInvoiceLineItemsByInvoiceID(ctx context.Context, id string) ([]billing.InvoiceLineItem, error) {
q.singleReads++
return q.Querier.GetInvoiceLineItemsByInvoiceID(ctx, id)
}
// periodPages holds the member's and the operator's handlers over one billing
// querier, and the session each reads with: the member's carries the
// organization's id, the operator's the operator role.
type periodPages struct {
member *MemberInvoicesHandler
memberCtx context.Context
operator *OperatorPartialsHandler
operatorCtx context.Context
}
// newPeriodPages builds both handlers over billingQ and signs in the
// organization's owner and an operator.
func newPeriodPages(t *testing.T, w *world.World, org world.Org, billingQ billing.Querier) *periodPages {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
sm := authtest.NewSessionManager()
member, err := NewMemberInvoicesHandler(MemberInvoicesConfig{
BillingQ: billingQ,
AuthConfig: &auth.Config{SessionManager: sm},
Logger: logger,
})
if err != nil {
t.Fatalf("NewMemberInvoicesHandler: %v", err)
}
operator, err := NewOperatorPartialsHandler(OperatorPartialsConfig{
BillingQ: billingQ,
OrgQ: organization.New(w.DB),
StripeQ: stripedb.New(w.DB),
Database: w.DB,
Logger: logger,
AuthConfig: &auth.Config{SessionManager: sm},
})
if err != nil {
t.Fatalf("NewOperatorPartialsHandler: %v", err)
}
person := org.Owner.SignIn()
person.OrgID = org.ID
operatorPerson := w.Person("Operator").SignIn()
operatorPerson.Roles = []string{OperatorRole}
return &periodPages{
member: member, memberCtx: authtest.SignIn(t, sm, person),
operator: operator, operatorCtx: authtest.SignIn(t, sm, operatorPerson),
}
}
func (p *periodPages) memberPage(t *testing.T, invoiceID string) *htmltest.Node {
t.Helper()
rec := authtest.Get(t, p.memberCtx, p.member.GetInvoiceDetail, "/partials/member/invoices/"+invoiceID,
authtest.PathValue("invoiceID", invoiceID))
return htmltest.Parse(t, rec.Body.String())
}
func (p *periodPages) operatorPage(t *testing.T, invoiceID string) *htmltest.Node {
t.Helper()
rec := authtest.Get(t, p.operatorCtx, p.operator.GetOperatorInvoiceDetailPage, "/operator/billing/invoices/"+invoiceID,
authtest.PathValue("invoiceID", invoiceID))
return htmltest.Parse(t, rec.Body.String())
}
// memberPeriod is the period beside the member invoice page's status badge.
func memberPeriod(t *testing.T, page *htmltest.Node) string {
t.Helper()
return page.One(t, ".d-flex.flex-wrap > span.text-muted").Text()
}
// operatorPeriod is the paragraph of the operator's page that begins "Period:".
func operatorPeriod(page *htmltest.Node) string {
for _, p := range page.All("p") {
if text := p.Text(); strings.HasPrefix(text, "Period:") {
return text
}
}
return ""
}
// periodInvoice is one invoice of the fixture: its row's period as Stripe
// sends it, its issue time, its lines, and the period every page must show.
type periodInvoice struct {
number string
rowStart, rowEnd time.Time
issued time.Time
lines []periodInvoiceLine
want string
}
type periodInvoiceLine struct {
cents int32
start, end time.Time
}
func TestInvoicePagesShowThePeriodTheirLinesPayFor(t *testing.T) {
database := dbtest.Open(t)
w := world.New(t, database)
org := w.Org("Period Cooperative", "personal", w.Person("Owner"))
account := w.BillingAccount(org, "Period Primary")
price := w.OneTimePrice(w.Product("Membership"), 1000)
invoices := []periodInvoice{
{"0001", noon(2026, time.October, 29), noon(2026, time.October, 29), noon(2026, time.October, 29),
[]periodInvoiceLine{{1000, noon(2026, time.October, 29), noon(2026, time.November, 29)}},
"Oct 29, 2026 – Nov 29, 2026"},
{"0002", noon(2026, time.October, 29), noon(2026, time.November, 29), noon(2026, time.November, 29),
[]periodInvoiceLine{{1000, noon(2026, time.November, 29), noon(2026, time.December, 29)}},
"Nov 29, 2026 – Dec 29, 2026"},
{"0003", noon(2026, time.November, 29), noon(2026, time.December, 10), noon(2026, time.December, 10),
[]periodInvoiceLine{
{-645, noon(2026, time.December, 10), noon(2026, time.December, 29)},
{30000, noon(2026, time.December, 10), noon(2027, time.December, 10)},
},
"Dec 10, 2026 – Dec 10, 2027"},
{"0004", noon(2026, time.December, 10), noon(2026, time.December, 12), noon(2026, time.December, 12),
nil, "—"},
}
f := periodFixture{pages: newPeriodPages(t, w, org, billing.New(database)), invoices: invoices, ids: map[string]string{}}
for _, inv := range invoices {
row := w.Invoice(account, world.InvoiceOpts{
Number: inv.number, PeriodStart: inv.rowStart, PeriodEnd: inv.rowEnd, IssuedAt: inv.issued,
})
for _, l := range inv.lines {
w.LineItem(row, price, "Plan", l.cents, l.start, l.end)
}
f.ids[inv.number] = row.ID
}
t.Run("member list", f.memberList)
t.Run("member invoice page", f.memberInvoicePage)
t.Run("operator invoice page", f.operatorInvoicePage)
t.Run("operator line rows", f.operatorLineRows)
}
type periodFixture struct {
pages *periodPages
invoices []periodInvoice
ids map[string]string
}
func (f periodFixture) memberList(t *testing.T) {
rec := authtest.Get(t, f.pages.memberCtx, f.pages.member.GetInvoices, "/partials/member/invoices")
list := htmltest.Parse(t, rec.Body.String())
for _, inv := range f.invoices {
var cells []*htmltest.Node
for _, row := range list.All("tbody tr") {
if row.One(t, "th").Text() == inv.number {
cells = row.All("td")
}
}
if len(cells) == 0 {
t.Errorf("the list has no row %s:\n%s", inv.number, list.HTML())
continue
}
if got := cells[0].Text(); got != inv.want {
t.Errorf("the list's row %s reads period %q, want %q", inv.number, got, inv.want)
}
}
}
func (f periodFixture) memberInvoicePage(t *testing.T) {
for _, inv := range f.invoices {
if got := memberPeriod(t, f.pages.memberPage(t, f.ids[inv.number])); got != inv.want {
t.Errorf("the member page of invoice %s reads period %q, want %q", inv.number, got, inv.want)
}
}
}
func (f periodFixture) operatorInvoicePage(t *testing.T) {
for _, inv := range f.invoices {
want := "Period: " + inv.want
if got := operatorPeriod(f.pages.operatorPage(t, f.ids[inv.number])); got != want {
t.Errorf("the operator page of invoice %s reads %q, want %q", inv.number, got, want)
}
}
}
func (f periodFixture) operatorLineRows(t *testing.T) {
page := f.pages.operatorPage(t, f.ids["0003"])
want := []string{"Dec 10, 2026 – Dec 29, 2026", "Dec 10, 2026 – Dec 10, 2027"}
rows := page.All("table.table-record tbody tr")
for i, wantPeriod := range want {
got := "no row"
if i < len(rows) {
got = rows[i].One(t, "td.text-nowrap:last-child").Text()
}
if got != wantPeriod {
t.Errorf("the operator page's line %d of invoice 0003 reads period %q, want %q", i+1, got, wantPeriod)
}
}
}
func TestInvoicePagesShowNoPeriodWhenTheirLinesCannotBeRead(t *testing.T) {
database := dbtest.Open(t)
w := world.New(t, database)
org := w.Org("Unread Cooperative", "personal", w.Person("Owner"))
account := w.BillingAccount(org, "Unread Primary")
price := w.OneTimePrice(w.Product("Membership"), 1000)
invoice := w.Invoice(account, world.InvoiceOpts{
Number: "0001", PeriodStart: noon(2026, time.October, 29), PeriodEnd: noon(2026, time.October, 29),
IssuedAt: noon(2026, time.October, 29),
})
w.LineItem(invoice, price, "Plan", 1000, noon(2026, time.October, 29), noon(2026, time.November, 29))
pages := newPeriodPages(t, w, org, periodLinesFailingQ{Querier: billing.New(database)})
t.Run("member invoice page", func(t *testing.T) {
if got, want := memberPeriod(t, pages.memberPage(t, invoice.ID)), "—"; got != want {
t.Errorf("the member page of an invoice whose lines cannot be read reads period %q, want %q", got, want)
}
})
t.Run("operator invoice page", func(t *testing.T) {
if got, want := operatorPeriod(pages.operatorPage(t, invoice.ID)), "Period: —"; got != want {
t.Errorf("the operator page of an invoice whose lines cannot be read reads %q, want %q", got, want)
}
})
}
// TestMemberInvoiceListReadsItsLinesInOneStatement pins design D4: the Billing
// list reads the lines of all its invoices in one statement, never one per
// invoice.
func TestMemberInvoiceListReadsItsLinesInOneStatement(t *testing.T) {
database := dbtest.Open(t)
w := world.New(t, database)
org := w.Org("Counted Cooperative", "personal", w.Person("Owner"))
account := w.BillingAccount(org, "Counted Primary")
price := w.OneTimePrice(w.Product("Membership"), 1000)
for _, number := range []string{"0001", "0002", "0003"} {
inv := w.Invoice(account, world.InvoiceOpts{
Number: number, PeriodStart: noon(2026, time.October, 29), PeriodEnd: noon(2026, time.October, 29),
IssuedAt: noon(2026, time.October, 29),
})
w.LineItem(inv, price, "Plan", 1000, noon(2026, time.October, 29), noon(2026, time.November, 29))
}
counting := &periodCountingQ{Querier: billing.New(database)}
pages := newPeriodPages(t, w, org, counting)
authtest.Get(t, pages.memberCtx, pages.member.GetInvoices, "/partials/member/invoices")
if counting.batchReads != 1 || counting.singleReads != 0 {
t.Errorf("the list of three invoices read their lines in %d batch statements and %d per-invoice statements, want 1 and 0",
counting.batchReads, counting.singleReads)
}
}
+75 -5
View File
@@ -161,8 +161,57 @@ func invoiceBelongsToOrg(accounts []billing.Account, billingAccountID string) bo
return false
}
// formatInvoicePeriod renders an invoice's billing period as a human-readable
// range, falling back to a single bound or an em dash when bounds are absent.
// invoicePeriodFromLines is the period an invoice pays for: from the earliest
// start to the latest end of its stored lines that count, or two invalid
// times when none does. Stripe's invoice-level period, which the invoice row
// holds, looks back one period on a subscription invoice, so it is never an
// invoice's period here; a line carries the service period it bills.
//
// A line is left out when:
// - its amount is below zero: a credit returns time the invoice does not
// pay for, such as the unused time an upgrade gives back;
// - its amount is zero and another line is above zero: it is the credit of
// an upgrade from a $0 price, and counting it would stretch the period to
// the end of that price's period. On an invoice with no line above zero (a
// free price, a trial) the lines of zero are the only lines and show the
// period the invoice covers;
// - a bound of its period is NULL;
// - its period ends no later than it starts: an invoice item given no
// period of its own carries one that begins and ends at its creation, and
// that moment marks when the item was added, not a span of service.
func invoicePeriodFromLines(lines []billing.InvoiceLineItem) (start, end sql.NullTime) {
charged := false
for _, li := range lines {
if li.Amount > 0 {
charged = true
}
}
for _, li := range lines {
if li.Amount < 0 {
continue
}
if charged && li.Amount == 0 {
continue
}
if !li.PeriodStart.Valid || !li.PeriodEnd.Valid {
continue
}
if !li.PeriodEnd.Time.After(li.PeriodStart.Time) {
continue
}
if !start.Valid || li.PeriodStart.Time.Before(start.Time) {
start = li.PeriodStart
}
if !end.Valid || li.PeriodEnd.Time.After(end.Time) {
end = li.PeriodEnd
}
}
return start, end
}
// formatInvoicePeriod renders a period, an invoice's from invoicePeriodFromLines
// or one line's, as a range of dates, one date when one bound is set, and the
// empty-value marker when neither is.
func formatInvoicePeriod(start, end sql.NullTime) string {
const layout = "Jan 2, 2006"
switch {
@@ -194,7 +243,8 @@ func paymentOutcomeTime(paidAt, failedAt sql.NullTime) sql.NullTime {
// GetInvoices handles GET /partials/member/invoices. It lists the invoices for
// the member's organization, aggregated across all of the org's billing
// accounts, most-recent first. The result is scoped strictly to the member's
// own organization via session.OrgID.
// own organization via session.OrgID. The list reads the listed invoices'
// lines in one statement for their periods.
func (h *MemberInvoicesHandler) GetInvoices(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
@@ -247,11 +297,30 @@ func (h *MemberInvoicesHandler) GetInvoices(w http.ResponseWriter, r *http.Reque
// every consumer of the stamp follows (stripe-environment-stamp D7).
invoices = h.invoicesInKeyEnvironment(r.Context(), invoices)
linesByInvoice := make(map[string][]billing.InvoiceLineItem)
if len(invoices) > 0 {
ids := make([]string, 0, len(invoices))
for _, inv := range invoices {
ids = append(ids, inv.InvoiceID)
}
lines, err := h.BillingQ.GetInvoiceLineItemsByInvoiceIDs(r.Context(), ids)
if err != nil {
h.Logger.Error("failed to list invoice lines",
slog.String("org_id", session.OrgID), slog.Any("error", err))
data.Error = "Failed to load billing history"
h.Templates.Render(w, "member_invoices.html", data)
return
}
for _, li := range lines {
linesByInvoice[li.InvoiceID] = append(linesByInvoice[li.InvoiceID], li)
}
}
for _, inv := range invoices {
data.Invoices = append(data.Invoices, InvoiceListItemViewModel{
InvoiceID: inv.InvoiceID,
InvoiceNumber: inv.InvoiceNumber.String,
Period: formatInvoicePeriod(inv.PeriodStart, inv.PeriodEnd),
Period: formatInvoicePeriod(invoicePeriodFromLines(linesByInvoice[inv.InvoiceID])),
AmountDue: formatCurrency(inv.AmountDue, inv.Currency),
AmountPaid: formatCurrency(inv.AmountPaid, inv.Currency),
Currency: inv.Currency,
@@ -322,7 +391,6 @@ func (h *MemberInvoicesHandler) GetInvoiceDetail(w http.ResponseWriter, r *http.
AmountDue: formatCurrency(invoice.AmountDue, invoice.Currency),
AmountPaid: formatCurrency(invoice.AmountPaid, invoice.Currency),
Currency: invoice.Currency,
Period: formatInvoicePeriod(invoice.PeriodStart, invoice.PeriodEnd),
}
if invoice.DueDate.Valid {
data.DueDate = invoice.DueDate.Time.Format("Jan 2, 2006")
@@ -347,10 +415,12 @@ func (h *MemberInvoicesHandler) GetInvoiceDetail(w http.ResponseWriter, r *http.
if err != nil {
h.Logger.Error("failed to list invoice line items",
slog.String("invoice_id", invoiceID), slog.Any("error", err))
data.Period = formatInvoicePeriod(sql.NullTime{}, sql.NullTime{})
data.Error = "Failed to load invoice line items"
h.Templates.Render(w, "member_invoice_detail.html", data)
return
}
data.Period = formatInvoicePeriod(invoicePeriodFromLines(lineItems))
for _, li := range lineItems {
data.LineItems = append(data.LineItems, MemberLineItemViewModel{
Description: li.Description.String,
+165
View File
@@ -5,12 +5,18 @@ package server
import (
"bytes"
"context"
"database/sql"
"io"
"log/slog"
"strings"
"testing"
"time"
"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/testkit/authtest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
)
// TestInvoiceBelongsToOrg exercises the ownership gate that produces the 404
@@ -141,3 +147,162 @@ func TestMemberInvoiceTemplatesRender(t *testing.T) {
}
}
}
// periodLine is one stored line of an invoice for the period rule: its amount
// in cents and its period, a zero time being NULL.
type periodLine struct {
amount int32
start, end time.Time
}
// TestInvoicePeriodFromLines reads every branch of the rule through rows built
// on the cases of the design (D1): an invoice's period runs from the earliest
// start to the latest end of the lines that count.
func TestInvoicePeriodFromLines(t *testing.T) {
day := func(month time.Month, d int) time.Time { return noon(2026, month, d) }
oneOff := time.Date(2026, time.October, 29, 10, 0, 0, 0, time.UTC)
rows := []struct {
name string
lines []periodLine
start, end time.Time
}{
{"one subscription line", []periodLine{
{1000, day(time.October, 29), day(time.November, 29)},
}, day(time.October, 29), day(time.November, 29)},
{"an upgrade's credit and charge", []periodLine{
{-633, day(time.November, 10), day(time.November, 29)},
{1267, day(time.November, 10), day(time.November, 29)},
}, day(time.November, 10), day(time.November, 29)},
{"an upgrade to a yearly price", []periodLine{
{-633, day(time.November, 10), day(time.November, 29)},
{30000, day(time.November, 10), noon(2027, time.November, 10)},
}, day(time.November, 10), noon(2027, time.November, 10)},
{"an upgrade to a monthly price", []periodLine{
{-21000, day(time.November, 10), noon(2027, time.March, 1)},
{4000, day(time.November, 10), day(time.December, 10)},
}, day(time.November, 10), day(time.December, 10)},
{"prorations folded into a renewal", []periodLine{
{-300, day(time.November, 20), day(time.November, 29)},
{600, day(time.November, 20), day(time.November, 29)},
{2000, day(time.November, 29), day(time.December, 29)},
}, day(time.November, 20), day(time.December, 29)},
{"an upgrade from a free yearly price", []periodLine{
{0, day(time.November, 10), noon(2027, time.March, 1)},
{4000, day(time.November, 10), day(time.December, 10)},
}, day(time.November, 10), day(time.December, 10)},
{"a free price", []periodLine{
{0, day(time.October, 29), day(time.November, 29)},
}, day(time.October, 29), day(time.November, 29)},
{"a trial", []periodLine{
{0, day(time.October, 29), day(time.November, 12)},
}, day(time.October, 29), day(time.November, 12)},
{"a one-off item", []periodLine{
{1500, oneOff, oneOff},
}, time.Time{}, time.Time{}},
{"a one-off item beside a renewal", []periodLine{
{1500, day(time.November, 20), day(time.November, 20)},
{2000, day(time.November, 29), day(time.December, 29)},
}, day(time.November, 29), day(time.December, 29)},
{"credits only", []periodLine{
{-500, day(time.November, 10), day(time.November, 29)},
}, time.Time{}, time.Time{}},
{"a line with no start", []periodLine{
{1000, time.Time{}, day(time.November, 29)},
}, time.Time{}, time.Time{}},
{"a trial beside a one-off fee", []periodLine{
{0, day(time.October, 29), day(time.November, 12)},
{1500, oneOff, oneOff},
}, time.Time{}, time.Time{}},
{"no lines", nil, time.Time{}, time.Time{}},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
var lines []billing.InvoiceLineItem
for _, l := range row.lines {
lines = append(lines, billing.InvoiceLineItem{
Amount: l.amount,
PeriodStart: sql.NullTime{Time: l.start, Valid: !l.start.IsZero()},
PeriodEnd: sql.NullTime{Time: l.end, Valid: !l.end.IsZero()},
})
}
checkInvoicePeriodFromLines(t, row.name, lines, row.start, row.end)
})
}
}
// checkInvoicePeriodFromLines reports a period that differs from the wanted
// one, a zero time being none, comparing each bound by Valid and Time.Equal.
func checkInvoicePeriodFromLines(t *testing.T, name string, lines []billing.InvoiceLineItem, wantStart, wantEnd time.Time) {
t.Helper()
gotStart, gotEnd := invoicePeriodFromLines(lines)
same := func(got sql.NullTime, want time.Time) bool {
if got.Valid != !want.IsZero() {
return false
}
return !got.Valid || got.Time.Equal(want)
}
if !same(gotStart, wantStart) || !same(gotEnd, wantEnd) {
show := func(start, end sql.NullTime) string {
if !start.Valid && !end.Valid {
return "none"
}
bound := func(n sql.NullTime) string {
if !n.Valid {
return "none"
}
return n.Time.Format(time.RFC3339)
}
return bound(start) + " to " + bound(end)
}
t.Errorf("%s: the invoice's period is %s, want %s", name, show(gotStart, gotEnd),
show(sql.NullTime{Time: wantStart, Valid: !wantStart.IsZero()}, sql.NullTime{Time: wantEnd, Valid: !wantEnd.IsZero()}))
}
}
// linesFailingBillingQ answers the member's invoice list up to its read of the
// listed invoices' lines, which fails.
type linesFailingBillingQ struct {
billing.Querier
invoice billing.Invoice
}
func (q linesFailingBillingQ) ListBillingAccountsByOrgID(context.Context, string) ([]billing.Account, error) {
return []billing.Account{{BillingAccountID: q.invoice.BillingAccountID}}, nil
}
func (q linesFailingBillingQ) GetInvoicesByBillingAccountID(context.Context, string) ([]billing.Invoice, error) {
return []billing.Invoice{q.invoice}, nil
}
func (q linesFailingBillingQ) GetInvoiceLineItemsByInvoiceIDs(context.Context, []string) ([]billing.InvoiceLineItem, error) {
return nil, errReadFailed
}
// TestMemberInvoiceListShowsTheLoadErrorWhenItsLinesCannotBeRead pins design
// F1: a list whose periods cannot be computed is not shown, as a list of "—"
// would state that no invoice has a period.
func TestMemberInvoiceListShowsTheLoadErrorWhenItsLinesCannotBeRead(t *testing.T) {
issued := sql.NullTime{Time: noon(2026, time.October, 29), Valid: true}
q := linesFailingBillingQ{invoice: billing.Invoice{
InvoiceID: "0b1f3c3a-0000-4000-8000-000000000001",
BillingAccountID: "0b1f3c3a-0000-4000-8000-000000000002",
InvoiceNumber: sql.NullString{String: "0001", Valid: true},
Status: "open", AmountDue: 1000, Currency: "usd", IssuedAt: issued,
}}
sm := authtest.NewSessionManager()
h, err := NewMemberInvoicesHandler(MemberInvoicesConfig{
BillingQ: q,
AuthConfig: &auth.Config{SessionManager: sm},
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
t.Fatalf("NewMemberInvoicesHandler: %v", err)
}
ctx := authtest.SignIn(t, sm, auth.SignIn{OrgID: "0b1f3c3a-0000-4000-8000-000000000003"})
rec := authtest.Get(t, ctx, h.GetInvoices, "/partials/member/invoices")
page := htmltest.Parse(t, rec.Body.String())
alerts := page.All(".alert-danger")
if len(alerts) != 1 || !strings.Contains(alerts[0].Text(), "Failed to load billing history") || page.Count("tbody tr") != 0 {
t.Errorf("a list whose lines cannot be read rendered:\n%s\nwant the load error and no row", rec.Body.String())
}
}
+4 -2
View File
@@ -959,7 +959,8 @@ type OperatorInvoiceDetailData struct {
// (design D7, tasks 6.1/6.3): reuses the same projection queries the
// member invoice-detail view reads (GetInvoiceByID,
// GetInvoiceLineItemsByInvoiceID — member_invoices.go) plus the
// operator-only organization and Stripe-mapping reads. Returns ok=false
// operator-only organization and Stripe-mapping reads. The invoice's period
// comes from the lines it reads (invoicePeriodFromLines). Returns ok=false
// when the invoice ID does not resolve, so the caller can answer with the
// panel's ordinary 404 rather than an inline error banner (operator-
// billing-views: "Unknown invoice IDs 404 in the shell").
@@ -982,7 +983,6 @@ func (h *OperatorPartialsHandler) loadOperatorInvoiceDetailData(r *http.Request,
AmountDue: formatCurrency(invoice.AmountDue, invoice.Currency),
AmountPaid: formatCurrency(invoice.AmountPaid, invoice.Currency),
Currency: invoice.Currency,
Period: formatInvoicePeriod(invoice.PeriodStart, invoice.PeriodEnd),
}
if invoice.DueDate.Valid {
data.DueDate = invoice.DueDate.Time.Format("Jan 2, 2006")
@@ -1017,9 +1017,11 @@ func (h *OperatorPartialsHandler) loadOperatorInvoiceDetailData(r *http.Request,
lineItems, err := h.BillingQ.GetInvoiceLineItemsByInvoiceID(ctx, invoiceID)
if err != nil {
h.Logger.Error("failed to list invoice line items", slog.Any("error", err), slog.String("invoice_id", invoiceID))
data.Period = formatInvoicePeriod(sql.NullTime{}, sql.NullTime{})
data.Error = "Failed to load invoice line items"
return data, true
}
data.Period = formatInvoicePeriod(invoicePeriodFromLines(lineItems))
for _, li := range lineItems {
data.LineItems = append(data.LineItems, OperatorInvoiceLineItemViewModel{
Description: li.Description.String,
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-30
@@ -0,0 +1,176 @@
## Context
**What the pages show.** The member's Billing page lists each invoice with a Period column (`internal/embeds/templates/partials/member_invoices.html:26`, `:42`), the member's invoice page shows the period beside the status badge (`member_invoice_detail.html:19`), and the operator's invoice page shows "Period: {period}" above its line table (`operator_billing_invoice_detail.html:61`), whose rows carry each line's own period (`:78`, `:87`). The three invoice periods come from `formatInvoicePeriod(invoice.PeriodStart, invoice.PeriodEnd)`: `GetInvoices` (`internal/server/member_invoices.go:254`), `GetInvoiceDetail` (`:325`) and `loadOperatorInvoiceDetailData` (`internal/server/operator_billing.go:985`). The line rows of the operator's page call the same function with the line's bounds (`:1028`). `formatInvoicePeriod` (`member_invoices.go:164-178`) joins two dates in the layout "Jan 2, 2006" with " – " (U+2013), gives one date when one bound is set, and gives the empty-value marker "—" (U+2014) when neither is. No other page, email or export shows an invoice's period: the operator's invoice list selects the columns (`internal/billing/queries/invoices.sql:155-156`) and shows no period, and the console sends no invoice email.
**What the console stores.** `core.invoices` has `period_start` and `period_end` (`internal/db/migrations/00001_init.sql:507-508`); `core.invoice_line_items` has its own `period_start` and `period_end` (`:537-538`) and a signed `amount` with no check on its sign (`:533`). The invoice reconcile writes them:
- `createInvoice` (`internal/integrations/stripe/workflows/invoice_reconcile.go:518-557`) writes Stripe's invoice-level `period_start` and `period_end` (`:531-532`) once, when it creates the row. `convergeInvoice` (`:559-583`) never writes the period, so a row keeps the period of the read that created it.
- `insertLines` (`:613-691`) inserts lines only into an invoice that has none (`:617-623`), and never changes them afterwards. It skips a line with no price or with a price the console did not create (`:629-641`). It stores Stripe's signed line amount (`convertAmounts`, `:298-307`) and the line's `period` (`:658-672`).
- `converge` creates the invoice and inserts its lines in one transaction (`:447`, `:465`), so a reader never finds an invoice the reconcile wrote without the lines of Stripe's response that the reconcile stores. An invoice has no stored line when every line was skipped, or when it was projected from an event payload before the read-back existed and the sweep has not yet read its lines (`docs/stripe.md`, "Upgrading to the invoice read-back", step 4).
- A draft is never stored (`:153-157`), so no page lists one.
`GetInvoiceLineItemsByInvoiceID` returns an invoice's lines in insert order, which is Stripe's line order (`internal/billing/queries/invoice_line_items.sql:10-15`). `scripts/db-reader-role.sql` grants the `console_reader` role one column at a time, and a column a later migration adds stays unreadable until the script runs again (`:25-29`).
**What Stripe says** (API version 2025-02-24.acacia, the version stripe-go v81.4.0 pins, `api_version.go:10`; pages read with `stripe docs` at that version, no API call made):
- **The invoice's `period_start` and `period_end`**: "Start [End] of the usage period during which invoice items were added to this invoice. This looks back one period for a subscription invoice. Use the line item period to get the service period for each price." (API reference, The Invoice object, `https://docs.stripe.com/api/invoices/object?api-version=2025-02-24.acacia`; stripe-go `invoice.go:3314-3317`.) A purchase's first invoice has no earlier period, so both bounds fall at the purchase, as the issue observed.
- **A line's `period`**: "The period this line_item covers. For subscription line items, this is the subscription period. For prorations, this starts when the proration was calculated, and ends at the period end of the subscription. For invoice items, this is the time at which the invoice item was created or the period of the item." (API reference, The Invoice Line Item object, `https://docs.stripe.com/api/invoice-line-item/object?api-version=2025-02-24.acacia`.) A line also carries `proration` and `type` (`invoiceitem` or `subscription`); the console stores neither.
- **A proration's credit and charge**: the example of "Prorations" (`https://docs.stripe.com/billing/subscriptions/prorations`) shows a credit line "Unused time on Silver plan after 01 Sep 2020" of −166 and a charge line "Remaining time on Gold plan after 01 Sep 2020" of 541, both with a period from the proration moment to the period's end, beside the next period's subscription line, on an invoice whose own `period_start` and `period_end` are the month before. The example is a preview at a later API version; its line periods match the 2025-02-24.acacia definition above.
- **A switch across intervals**: "If the prices have different billing periods, the new price is billed at the new interval, starting on the day of the change." ("Change the price of existing subscriptions", Billing periods, `https://docs.stripe.com/billing/subscriptions/change-price`.) The charge line's period therefore starts at the switch and runs one new interval; the credit line runs from the switch to the old period's end.
- **An invoice item without a period**: Revenue Recognition treats its line as having no service period ("line item periods aren't automatically populated" on standalone invoices; "If you don't set a period on an invoice line item, the amount on that invoice line item is recognized immediately", `https://docs.stripe.com/revenue-recognition/methodology/subscriptions-and-invoicing`, Standalone invoices).
- **A void invoice** keeps its lines; nothing in the reference ties a line's period or the invoice's to its status.
**The console's own flows** produce these invoices: a purchase and each renewal; an upgrade invoiced at once with `always_invoice` (`internal/fulfillment/plan_change.go:350-357`), whose invoice holds a credit and a charge, and across intervals a credit and a new period (#287); a downgrade at the renewal with no proration (`:483`); a cancellation with no proration and no invoice (`:808`); an abandoned upgrade's invoice, voided (`:341-348`); and a renewal voided under the collection pause the console sets for a grant (`docs/stripe.md:216`). The scheduled-change sweep's switch sends `create_prorations` (`internal/fulfillment/sweep.go:208-215`), which leaves its credit and charge for the next renewal invoice. No console flow makes an invoice of credits only.
**The demo and the screens.** The demo seed issues three invoices on Bob's account, each with one line, and writes the invoice's period equal to the line's (`internal/demoseed/demoseed.go:926-981`, `:964`, `:976`). The screens capture the member's Billing page (`test/e2e/screens/manifest.go:98`) and the operator's invoice page of the first invoice its list links (`:71-72`), demo invoice 0003. The member's invoice page is an HTMX partial outside the manifest.
## Goals / Non-Goals
**Goals:**
- Every page that shows an invoice's period shows the period its lines pay for, by one rule.
- Invoices already stored show it without a migration or a backfill.
- The demo database holds invoices whose stored invoice-level period differs from their lines', as Stripe's do.
**Non-Goals:**
- A period on the member's line rows.
- Storing the lines the reconcile skips, or Stripe's `type` and `proration` per line.
- Removing Stripe's invoice-level period from the database.
## Decisions
### D1. The rule
Pick: **an invoice's period runs from the earliest start to the latest end of its stored lines that count. A line counts when its amount is above zero and its period has both bounds, the end after the start. When no line on the invoice has an amount above zero, the lines of amount zero count instead, under the same test of the period. With no line that counts, the invoice has no period, and the pages show the empty-value marker.**
The amount's sign leaves out credits: a negative line returns time the customer paid for earlier, and the invoice does not pay for that time. A line of zero beside a charge leaves out too: it is the credit of an upgrade from a $0 yearly price to a monthly one (C16), and counting it would read the rest of the year, R1's error. A line of zero counts only when the invoice has no line above zero, which keeps the invoice of a free price, and of a trial, showing the period it covers (C7, C17). An invoice whose only line above zero has no period of its own shows "—" even beside a line of zero with a period (C18), because the invoice charges for nothing that has a period. The end-after-start test leaves out an invoice item given no period of its own, whose period is the moment of its creation (Context): it marks when the item was added, not a span of service.
Options weighed, against the cases below:
- **R1. Every line's span, credits included.** Rejected: an upgrade from a yearly price to a monthly one (C5) carries a credit to the end of the year, and the invoice would read the rest of the year although it pays for one month.
- **R2. Only lines above zero.** Rejected: the invoice of a free price (C17) and of a trial (C7) would show no period, though each covers one.
- **R6. Every line of zero or more.** Rejected: an upgrade from a $0 yearly price to a monthly one (C16) carries a credit of zero to the end of the year, and the invoice would read R1's error.
- **R3. Stripe's `type` and `proration`** (the subscription line, or a proration that is not a credit). Rejected: the console stores neither field, so the rule would need a migration and a reconcile change, and it would still need the sign to tell a proration's credit from its charge. It also leaves out an invoice item that was given a period (C9).
- **R4. Only the subscription line's period.** Rejected: an invoice that carries prorations beside a renewal (C6) pays for the span since the change too, and R4 needs `type` as R3 does.
- **R5 (chosen).** The amount's sign, whether the period's end follows its start, and whether the invoice has a line above zero, over the stored lines. It needs no field the console does not store, and it reads every case below from the line periods Stripe defines.
### D2. Computed when a page is read, from the stored lines
Pick: **a pure Go function, `invoicePeriodFromLines(lines []billing.InvoiceLineItem) (start, end sql.NullTime)`, beside `formatInvoicePeriod` in `internal/server/member_invoices.go`, called by the three pages on the lines they read.** The two invoice pages already read the invoice's lines (`member_invoices.go:346`, `operator_billing.go:1017`). The member's Billing page reads the lines of all the invoices it lists in one statement, `GetInvoiceLineItemsByInvoiceIDs`, after it drops the invoices of the other Stripe environment (`member_invoices.go:248`), and groups them by invoice.
Options weighed:
- **A (chosen). At read, in Go, from the stored lines.** The rule lives in one function with a unit test per case. Every invoice already stored shows the right period on the next load, and an invoice whose lines arrive on a later read shows their period on the next load, with nothing to recompute. The period agrees with the lines the pages list, because both come from the same rows. Cost: one more statement on the member's Billing page.
- **B. The reconcile writes the line-derived period into `core.invoices.period_start` and `period_end`.** Rejected. Existing rows need a backfill, which is a second implementation of the rule in a migration's SQL. `convergeInvoice` must also write the period, so that an invoice whose lines arrive on a later read moves with them. The rule would read Stripe's lines, including the ones `insertLines` skips, so a page could show a period that its listed lines do not cover. Stripe's own value would be lost.
- **C. The reconcile writes new columns.** Rejected for B's reasons, less the lost value, plus a migration under `docs/database-management.md`, a run of `scripts/db-reader-role.sql` after the deploy, and a stored copy of a value the pages compute from rows they read.
- **D. At read, in SQL** (an aggregate joined into `GetInvoicesByBillingAccountID` and `GetInvoiceByID`). Rejected: the rule would be written in two queries, or in a SQL function that needs a migration, while the two invoice pages already hold the lines in Go.
- **E. Fall back to Stripe's invoice-level period when no line counts.** Rejected: for a one-off invoice that fallback reads the moment of creation as a range (C8), and for an invoice whose lines are not stored the console does not know the period the invoice pays for (C12). The marker says so.
### D3. Stripe's invoice-level period stays stored and is shown nowhere
Pick: **the reconcile keeps writing Stripe's `period_start` and `period_end` on the invoice row (`invoice_reconcile.go:531-532`), and a comment there says what they hold and that no page shows them.** `docs/stripe.md:129` already says what Stripe's value means, and `docs/models/payments-billing.md` gains it.
Rejected: dropping the columns. It is a destructive migration that this fix does not need, the value can be read again only from Stripe, and the reader role reads it when someone compares the console with Stripe. Rejected: renaming them, a migration and a sqlc change across the reconcile, the test kit, the demo seed and the sample-data seed, for a name the documents already explain.
### D4. The pages' reads and their failures
- **The member's Billing page** (`GetInvoices`, `member_invoices.go:198-264`): after the environment filter, when the list is not empty, it reads the lines of the listed invoices with `GetInvoiceLineItemsByInvoiceIDs` and groups them by `InvoiceID`; each row's `Period` is `formatInvoicePeriod(invoicePeriodFromLines(lines[inv.InvoiceID]))`. A failed read is logged and renders the page's existing load error, "Failed to load billing history" (`:211`, `:222`), as a failed read of the invoices does: a list whose periods are all "—" would state that no invoice has a period.
- **The member's invoice page** (`GetInvoiceDetail`): `Period` is set from the lines read at `:346`, by a statement placed between the read and its error check. A failed read leaves `lineItems` nil, so the period is the marker beside the page's existing "Failed to load invoice line items" (`:350`); today it shows Stripe's period there.
- **The operator's invoice page** (`loadOperatorInvoiceDetailData`): the same, with the lines read at `operator_billing.go:1017`; the `Period` field at `:985` goes.
The query (`internal/billing/queries/invoice_line_items.sql`):
```sql
-- name: GetInvoiceLineItemsByInvoiceIDs :many
-- The lines of several invoices in one read, for the member's invoice
-- list, which shows each invoice's period from its lines. Each invoice's
-- lines come in insert order, as GetInvoiceLineItemsByInvoiceID returns
-- them.
SELECT * FROM core.invoice_line_items
WHERE invoice_id = ANY(sqlc.arg(invoice_ids)::uuid[])
ORDER BY invoice_id, created_at ASC, line_item_id ASC;
```
`idx_invoice_line_items_invoice_id` (`00001_init.sql:547`) serves it. The Go argument is `[]string`, as `ListInvoicesPage`'s `invoice_ids` is (`internal/billing/invoices.sql.go:415`).
### D5. The demo seed carries Stripe's invoice-level period
Pick: **`issue` (`demoseed.go:955-981`) takes the start of Stripe's invoice-level period beside the line's period; the invoice row's period runs from that start to the line's start.** Invoice 0001, the purchase, begins and ends on Nov 1, 2025; 0002 runs from Nov 1, 2025 to Dec 1, 2025; 0003 from Dec 1, 2025 to Jan 1, 2026. The lines, the issue times and the due dates stay. The demo's pages then read each invoice's period from its line, as a deployment's do, and a page that read the invoice row would show the issue's defect on the demo.
The rows the seed's golden test prints change in four files (tasks 2.2); the pages show what they show today (tasks 5.1).
Rejected: adding a demo invoice of an upgrade (a credit and a charge). It adds a row to Bob's list, an event to the operator's activity feed and a new newest invoice to the operator's invoice list, so four screens change for a case the database test already pins.
### D6. Documents
`docs/stripe.md:129` states the invoice-level period and the line period; it gains how the pages compute an invoice's period, and says that the stored invoice-level period is shown nowhere (tasks 4.1). `docs/models/payments-billing.md:30`, the row of the money tables, gains what `core.invoices.period_start` and `period_end` hold (tasks 4.2). `docs/testing.md` is unchanged: no test kit part changes.
## Cases
A monthly plan bought on Oct 29, 2026 (period Oct 29 to Nov 29), unless the case says otherwise. "Today" is Stripe's invoice-level period, the text every page shows now; for an upgrade it is the span since the invoice before it (Context), which the issue saw as the minutes between two changes.
| # | Case | Stored lines (sign: period) | Today | After |
|---|---|---|---|---|
| C1 | Purchase | +: Oct 29, 2026 to Nov 29, 2026 | "Oct 29, 2026 – Oct 29, 2026" | "Oct 29, 2026 – Nov 29, 2026" |
| C2 | Renewal on Nov 29 | +: Nov 29 to Dec 29 | "Oct 29, 2026 – Nov 29, 2026" | "Nov 29, 2026 – Dec 29, 2026" |
| C3 | Upgrade on Nov 10 at the same interval (`always_invoice`) | −: Nov 10 to Nov 29 (unused time on the old tier); +: Nov 10 to Nov 29 (remaining time on the new) | "Oct 29, 2026 – Nov 10, 2026" | "Nov 10, 2026 – Nov 29, 2026" |
| C4 | Upgrade on Nov 10 from a monthly price to a yearly one (#287) | −: Nov 10 to Nov 29; +: Nov 10, 2026 to Nov 10, 2027 | "Oct 29, 2026 – Nov 10, 2026" | "Nov 10, 2026 – Nov 10, 2027" |
| C5 | Upgrade on Nov 10 from a yearly price (Mar 1, 2026 to Mar 1, 2027) to a monthly one | −: Nov 10, 2026 to Mar 1, 2027; +: Nov 10 to Dec 10 | "Mar 1, 2026 – Nov 10, 2026" | "Nov 10, 2026 – Dec 10, 2026" (R1 would read "Nov 10, 2026 – Mar 1, 2027") |
| C6 | A change made with `create_prorations` on Nov 20 (the sweep's scheduled switch, or a change in the Stripe Dashboard), billed with the renewal on Nov 29 | −: Nov 20 to Nov 29; +: Nov 20 to Nov 29; +: Nov 29 to Dec 29 | "Oct 29, 2026 – Nov 29, 2026" | "Nov 20, 2026 – Dec 29, 2026" |
| C7 | A subscription started with a 14-day trial (every line is zero) | 0: Oct 29 to Nov 12 | "Oct 29, 2026 – Oct 29, 2026" | "Oct 29, 2026 – Nov 12, 2026" |
| C8 | One-off invoice: one invoice item with no period of its own, on a price the console created | +: Oct 29 at one moment | Stripe's span, such as "Oct 29, 2026 – Oct 29, 2026" | "—" |
| C9 | One-off invoice item given a period | +: Nov 1 to Dec 1 | Stripe's span | "Nov 1, 2026 – Dec 1, 2026" |
| C10 | Renewal with an invoice item added on Nov 20 with no period | +: Nov 20 at one moment; +: Nov 29 to Dec 29 | "Oct 29, 2026 – Nov 29, 2026" | "Nov 29, 2026 – Dec 29, 2026" |
| C11 | Credits only (no console flow makes one; an operator's change in the Dashboard can) | −: Nov 10 to Nov 29 | Stripe's span | "—" |
| C12 | No stored line: every line's price is one the console did not create, or the row predates the read-back and the sweep has not read its lines | none | Stripe's span | "—"; once lines are stored, their period |
| C13 | Void invoice: an abandoned upgrade's, or a renewal voided under the console's collection pause | as C3, C4 or C2 | as C3, C4 or C2 | as C3, C4 or C2: the status does not enter the rule |
| C14 | Draft | never stored | not listed | not listed |
| C15 | Demo invoices 0001 to 0003 | +: Nov 1, 2025 to Dec 1, 2025; Dec 1, 2025 to Jan 1, 2026; Jan 1, 2026 to Feb 1, 2026 | "Nov 1, 2025 – Dec 1, 2025", "Dec 1, 2025 – Jan 1, 2026", "Jan 1, 2026 – Feb 1, 2026" (the seed's rows) | the same text, from the lines; the rows now carry Stripe's shape (D5) |
| C16 | Upgrade on Nov 10 from a $0 yearly price (Mar 1, 2026 to Mar 1, 2027) to a monthly one | 0: Nov 10, 2026 to Mar 1, 2027 (credit of zero); +: Nov 10 to Dec 10 | "Mar 1, 2026 – Nov 10, 2026" | "Nov 10, 2026 – Dec 10, 2026" (the zero allowance of R6 would read "Nov 10, 2026 – Mar 1, 2027") |
| C17 | Purchase of a free price (one line of zero) | 0: Oct 29 to Nov 29 | "Oct 29, 2026 – Oct 29, 2026" | "Oct 29, 2026 – Nov 29, 2026" |
| C18 | A trial's line of zero (Oct 29 to Nov 12) beside a one-off fee of 1500 given no period | 0: Oct 29 to Nov 12; +: Oct 29 at 10:00 to the same moment | "Oct 29, 2026 – Oct 29, 2026" | "—" (the fee is above zero, so the line of zero is left out, and the fee has no period) |
## Copy
No string is added, so none is pending approval. The strings the change touches, all existing:
| ID | String | Where | Today | After | Status |
|---|---|---|---|---|---|
| S1 | "Period" | Billing page column header (`member_invoices.html:26`) | Heads Stripe's invoice-level period | Heads the period the lines pay for | existing, unchanged |
| S2 | "Period: {period}" | Operator's invoice page (`operator_billing_invoice_detail.html:61`) | Stripe's invoice-level period | The period the lines pay for | existing, unchanged |
| S3 | "{start} – {end}", layout "Jan 2, 2006", joined by an en dash (U+2013) | `formatInvoicePeriod` (`member_invoices.go:170`) | The invoice row's bounds | The lines' bounds, same format | existing, unchanged; the en dash is a date range, not an em dash |
| S4 | "—" | `formatInvoicePeriod` (`member_invoices.go:176`) | Shown when the invoice row has neither bound, which no reconciled invoice reaches | Shown when no line counts (C8, C11, C12) and beside a failed read of the lines on the two invoice pages | existing, unchanged; the lone empty-value marker is exempt from the no-em-dash rule (`docs/design-system.md:573`) |
| S5 | "Failed to load billing history" | Billing page error (`member_invoices.go:211`, `:222`) | A failed read of the accounts or the invoices | Also a failed read of the listed invoices' lines | existing, unchanged |
| S6 | "Failed to load invoice line items" | Both invoice pages (`member_invoices.go:350`, `operator_billing.go:1020`) | Unchanged | Unchanged | existing, unchanged |
## Failure table
| # | State | Today | After |
|---|---|---|---|
| F1 | The read of the listed invoices' lines fails on the Billing page | No such read | The page logs the error and shows "Failed to load billing history" in place of the list |
| F2 | The read of the lines fails on the member's invoice page | The page shows Stripe's period and "Failed to load invoice line items" | The page shows "—" and the same error |
| F3 | The read of the lines fails on the operator's invoice page | As F2 | "Period: —" and the same error |
| F4 | An invoice has no stored line that counts | Stripe's period | "—"; on the Billing page, where Period is the only date column, the row then shows no date |
| F5 | A later read inserts lines into an invoice that had none | Stripe's period, unchanged | The lines' period on the next load |
| F6 | The reconcile commits an invoice between the Billing page's read of invoices and its read of lines | The invoice is not listed | The same: the read of lines takes only the listed invoices' ids |
## Locks and transactions
None. The pages read without a transaction, as they do today. The Billing page's two reads are separate statements; an invoice it lists was committed with its lines (Context), so the second read finds them. An invoice whose lines a later reconcile inserts between the two reads shows either "—" or their period, each true at its moment.
## Tests
`TestInvoicePeriodFromLines` reads every branch of the rule through rows built on the cases (C1, C3 to C8, C10, C11, C16 to C18, a line with no start, no lines); its breaks invert each comparison and drop each condition. `TestInvoicePagesShowThePeriodTheirLinesPayFor` builds, through the test kit, four invoices whose rows carry Stripe's invoice-level period and whose lines carry the service period (C1, C2, C4, C12), and reads the period on the three pages and each line row of the operator's page for invoice 0003; each break that makes a page read the invoice row, or another invoice's lines, fails it. `TestMemberInvoiceListShowsTheLoadErrorWhenItsLinesCannotBeRead` pins F1 with a querier whose read of lines fails, and `TestInvoicePagesShowNoPeriodWhenTheirLinesCannotBeRead` pins F2 and F3 with a querier that wraps the database one and fails only the read of one invoice's lines (the failing-querier shape of `internal/server/operator_activity_failures_db_test.go:31`). The demo seed's golden test pins D5. Each test claim is pinned by a break (`docs/testing.md`, "Prove a test with deliberate breaks"); `TestMemberInvoiceListReadsItsLinesInOneStatement` pins D4's one statement with a querier that counts the batch read and the per-invoice read of lines (one and zero) while the Billing page lists three invoices. The tasks list each break with the text its failing test prints.
## What is unverified
- **The invoice-level period of an upgrade's invoice** (C3 to C5). Stripe's definition says "the usage period during which invoice items were added"; the issue's sandbox reading saw the minutes between two changes. The "Today" texts for C3 to C5 assume it starts at the invoice before; the rule does not read it.
- **The lines of an upgrade across intervals** (C4, C5). Read from "Change the price of existing subscriptions" and the line reference, not run: this change makes no Stripe call. The rule depends only on the credit being negative and the charge's period starting at the switch.
- **A trial's line** (C7): its zero amount and its period are read from the reference's "for subscription line items, this is the subscription period", not observed.
- **A one-off item's period** (C8, C10): that it begins and ends at the item's creation is the reference's text ("the time at which the invoice item was created"); a sandbox invoice was not read.
## Risks / Trade-offs
- **An invoice whose lines the console does not store shows no period.** An operator who bills a member with a price the console did not create (a Dashboard one-off) gets "—" on that invoice where a date showed. Its invoice page still shows the due and paid dates, but on the Billing page Period is the only date column, so that row shows no date at all, where today it shows Stripe's span. The same holds for an invoice of one-off items with no period (C8) and an invoice of credits only (C11).
- **An invoice that carries prorations beside a renewal reads a period longer than one billing period** (C6). It is what the invoice pays for.
- **A column named `period_start` still holds Stripe's invoice-level period.** A later reader can repeat the defect; the reconcile's comment and the two documents say what the column holds.
- **The Billing page makes one more statement per load.** It is one indexed read for the whole unpaged list.
@@ -0,0 +1,51 @@
## Why
Three pages show an invoice's period: the member's Billing page in its Period column (`internal/embeds/templates/partials/member_invoices.html:26`, `:42`), the member's invoice page beside the status badge (`member_invoice_detail.html:19`), and the operator's invoice page as "Period: …" above the lines (`operator_billing_invoice_detail.html:61`). Each renders `formatInvoicePeriod(invoice.PeriodStart, invoice.PeriodEnd)` (`internal/server/member_invoices.go:254`, `:325`; `internal/server/operator_billing.go:985`), the `period_start` and `period_end` of the `core.invoices` row. The invoice reconcile writes those two columns from Stripe's invoice-level `period_start` and `period_end` when it creates the row (`internal/integrations/stripe/workflows/invoice_reconcile.go:531-532`).
On the API version the console pins, 2025-02-24.acacia (stripe-go v81.4.0, `api_version.go:10`), Stripe defines the invoice-level period as the "usage period during which invoice items were added to this invoice", says that it "looks back one period for a subscription invoice", and sends the reader to "the line item period to get the service period for each price" (API reference, The Invoice object, `period_start` and `period_end`; the same text in stripe-go `invoice.go:3314-3317`). So a purchase's invoice reads "Oct 29, 2026 – Oct 29, 2026" for a month that runs to Nov 29, a renewal's reads the month that just ended, and a mid-period upgrade's reads the span since the invoice before it, minutes when the two changes are minutes apart. A line's `period` is the service period it bills, and the reconcile already stores it on each `core.invoice_line_items` row (`invoice_reconcile.go:658-672`).
The demo does not show the defect, because the demo seed writes each invoice's period equal to its line's (`internal/demoseed/demoseed.go:964`, `:976`), a shape Stripe never sends for a subscription invoice.
## What Changes
- **An invoice's period is the period its lines pay for**, computed when a page is read from the invoice's stored lines: from the earliest start to the latest end of the lines that count. A line counts when its amount is above zero and its period ends after it starts; when no line on the invoice has an amount above zero, the lines of amount zero count instead, so the invoice of a free price still shows its period. A credit (a negative amount, such as the unused time an upgrade returns) does not count, because the invoice does not pay for that time, and neither does a line of zero beside a charge, such as the credit of an upgrade from a $0 yearly price; an invoice item given no period of its own carries a period that begins and ends at its creation, and does not count either. An invoice with no line that counts shows the empty-value marker "—" (design D1, D2).
- **The member's Billing page reads the lines of every invoice it lists in one statement**, a new query `GetInvoiceLineItemsByInvoiceIDs`; the member's and the operator's invoice pages compute the period from the lines they already read. A failed read of the list's lines shows the list's existing load error (design D4).
- **The invoice row keeps Stripe's invoice-level period**, which no page shows after this change; the reconcile's write gains a comment that says what the two columns hold (design D3).
- **The demo seed writes each invoice's period as Stripe sends it** (a purchase's beginning and ending at the purchase, a renewal's looking back one period), so the demo database holds the case the issue describes and its pages show each invoice's period from its line. Four demo seed goldens change in their invoice rows only (design D5).
- **Tests:** the rule's rows, a database test of the three pages (the operator's line rows included), a test of the list's failed read of lines, and a test of the two invoice pages' failed read of lines.
- **Docs:** `docs/stripe.md`, "Invoices and payments", says how the pages compute an invoice's period and what the stored invoice-level period is; `docs/models/payments-billing.md` says what the two columns hold.
## Out of scope
- **A period column on the member's invoice page.** The operator's invoice page shows each line's period; the member's lists description, quantity and amount, and this change adds nothing there.
- **The lines the reconcile does not store.** A line whose price is missing or is not one the console created is skipped (`invoice_reconcile.go:629-641`). Such a line counts toward no period, as it shows on no page.
- **Dropping or renaming `core.invoices.period_start` and `period_end`** (design D3).
- **The per-line period on the operator's invoice page.** A one-off item's line reads "Oct 29, 2026 – Oct 29, 2026" there, which is the line's own period as Stripe sends it.
- **`test/mockshot-seed.sql`.** Its invoices have no lines (`test/mockshot-seed.sql:367-398`), so their pages show the empty-value marker for the period, as they show no lines.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `member-invoice-history`: added "An invoice shows the period its lines pay for" (the rule, its cases, the marker, Stripe's invoice-level period never shown, the list's failed read of lines); "Member views organization invoice list" and "Member views invoice detail with line items and payments" name that period in place of "billing period".
- `operator-billing-views`: "Operator invoice detail is addressable" shows the invoice's period as `member-invoice-history` sets it.
## Impact
- **Code:** `internal/server/member_invoices.go` (`invoicePeriodFromLines`, `GetInvoices`, `GetInvoiceDetail`, the doc comment of `formatInvoicePeriod`), `internal/server/operator_billing.go` (`loadOperatorInvoiceDetailData`), `internal/billing/queries/invoice_line_items.sql` and the code `make sqlc-generate` writes from it, `internal/integrations/stripe/workflows/invoice_reconcile.go` (a comment), `internal/demoseed/demoseed.go`.
- **Schema, migrations, the `console_reader` role:** none. No column is added, so `scripts/db-reader-role.sql` needs no new run.
- **Stripe calls:** none.
- **Reads:** the member's Billing page makes one more statement per load, served by `idx_invoice_line_items_invoice_id` (`internal/db/migrations/00001_init.sql:547`).
- **Behaviour a deployment sees:** every invoice already stored shows its lines' period on the first page load after the deploy, with no backfill. An invoice with no stored line that counts shows "—" where it showed Stripe's invoice-level period.
- **Tests:** a new test of the rule, a new database test of the three pages, a new test of the list's failed read and one of the invoice pages'; four demo seed goldens rewritten (tasks 2.2). No other golden or test result changes.
- **Screens:** none change (tasks 5.1).
- **Docs:** `docs/stripe.md`, `docs/models/payments-billing.md`.
## Related issues
- **#293** is this change; it closes with it.
- **#287** (landed): an upgrade toward a price of another interval starts a new period at the switch; its invoice is case C4 and C5 of the design.
@@ -0,0 +1,112 @@
## ADDED Requirements
### Requirement: An invoice shows the period its lines pay for
The period that the member's Billing page, the member's invoice page and the operator's invoice page show for an invoice SHALL be computed when the page is read, from the invoice's stored line items, and SHALL run from the earliest start to the latest end of the lines that count. A line SHALL count when its amount is above zero and its period has both a start and an end, the end after the start. When no line of the invoice has an amount above zero, the lines whose amount is zero and whose period has both bounds, the end after the start, SHALL count instead. A credit line (a negative amount, such as the unused time an upgrade returns) SHALL NOT count, because the invoice does not pay for that time, and a line of zero SHALL NOT count beside a line above zero; a line whose period begins and ends at one moment (an invoice item given no period of its own) SHALL NOT count either. An invoice with no line that counts, including an invoice with no stored line, SHALL show the empty-value marker in place of a period. Stripe's invoice-level period (the invoice's `period_start` and `period_end`), which the console stores and which looks back one period on a subscription invoice, SHALL NOT be shown as an invoice's period. The period SHALL render as two dates joined by an en dash, "Oct 29, 2026 – Nov 29, 2026".
When the Billing page cannot read the lines of the invoices it lists, it SHALL show the error it shows when it cannot read the invoices, in place of the list. When an invoice page cannot read the invoice's lines, its period SHALL be the empty-value marker beside that page's error.
#### Scenario: A purchase shows the month it buys
- **WHEN** a member opens the Billing page, and the invoice of a monthly subscription bought on Oct 29, 2026 has one line whose period runs from Oct 29, 2026 to Nov 29, 2026, while Stripe's invoice-level period begins and ends on Oct 29, 2026
- **THEN** the invoice's row reads "Oct 29, 2026 – Nov 29, 2026"
#### Scenario: A renewal shows the period it charges for
- **WHEN** a renewal invoice's line runs from Nov 29, 2026 to Dec 29, 2026 and Stripe's invoice-level period runs from Oct 29, 2026 to Nov 29, 2026
- **THEN** the invoice's row on the Billing page and the member's invoice page read "Nov 29, 2026 – Dec 29, 2026"
#### Scenario: An upgrade's credit does not count
- **WHEN** an upgrade made on Nov 10, 2026 from a yearly price to a monthly one is invoiced with a credit line for the unused year, from Nov 10, 2026 to Mar 1, 2027, and a charge line from Nov 10, 2026 to Dec 10, 2026
- **THEN** the invoice reads "Nov 10, 2026 – Dec 10, 2026"
#### Scenario: An upgrade to a yearly price shows the year it starts
- **WHEN** an upgrade made on Nov 10, 2026 from a monthly price to a yearly one is invoiced with a credit line from Nov 10, 2026 to Nov 29, 2026 and a charge line from Nov 10, 2026 to Nov 10, 2027
- **THEN** the invoice reads "Nov 10, 2026 – Nov 10, 2027"
#### Scenario: Prorations billed with a renewal widen the period
- **WHEN** a renewal invoice carries a credit line and a charge line from Nov 20, 2026 to Nov 29, 2026 beside the renewal's line from Nov 29, 2026 to Dec 29, 2026
- **THEN** the invoice reads "Nov 20, 2026 – Dec 29, 2026"
#### Scenario: A credit of zero does not count
- **WHEN** an upgrade made on Nov 10, 2026 from a yearly price of $0 to a monthly one is invoiced with a line of zero for the unused year, from Nov 10, 2026 to Mar 1, 2027, and a charge line from Nov 10, 2026 to Dec 10, 2026
- **THEN** the invoice reads "Nov 10, 2026 – Dec 10, 2026"
#### Scenario: A free price's invoice shows its period
- **WHEN** an invoice of a price of $0 has one line of zero from Oct 29, 2026 to Nov 29, 2026, and no line above zero
- **THEN** the invoice reads "Oct 29, 2026 – Nov 29, 2026"
#### Scenario: A trial's invoice shows the trial
- **WHEN** a subscription's first invoice carries one line of zero for a trial from Oct 29, 2026 to Nov 12, 2026
- **THEN** the invoice reads "Oct 29, 2026 – Nov 12, 2026"
#### Scenario: An invoice of one-off items shows no period
- **WHEN** an invoice's only line is an invoice item whose period begins and ends at the moment the item was created
- **THEN** the invoice shows the empty-value marker in place of a period
#### Scenario: An invoice without stored lines shows no period
- **WHEN** an invoice has no stored line, because each of its lines carries a price the console did not create
- **THEN** the invoice shows the empty-value marker, not Stripe's invoice-level period
#### Scenario: The list's lines cannot be read
- **WHEN** the Billing page lists an organization's invoices and its read of their lines fails
- **THEN** it shows "Failed to load billing history" in place of the list, as it does when the invoices cannot be read
## MODIFIED Requirements
### Requirement: Member views organization invoice list
The system SHALL provide a member-facing Billing page that lists the invoices belonging to the member's current organization. The list SHALL be scoped to the billing accounts of the member's organization (resolved by `org_id`), SHALL display each invoice's number (the platform-assigned reference number, leading the row; the empty-value marker with the tooltip "Assigned when the invoice is issued." when absent), the period its lines pay for (Requirement: An invoice shows the period its lines pay for), amount due, amount paid, currency, and status, and SHALL be ordered by when each invoice was issued, most recent first, across all of the organization's billing accounts, with ties broken as stripe-invoice-payment-projection sets (Requirement: Invoices and payments are listed by when they happened). Invoice amounts are stored in minor units (cents) and SHALL be rendered as human-readable currency.
#### Scenario: Member with invoices sees them listed
- **WHEN** an authenticated member whose organization has one or more synced invoices opens the Billing page
- **THEN** the system displays each invoice for that organization's billing account(s) with its number, period, amount due, amount paid, currency, and a status badge, ordered by issue time, newest first
#### Scenario: The list follows the issue time, not the recording time
- **WHEN** an invoice Stripe issued in March is projected after an invoice issued in April
- **THEN** the April invoice is listed above the March invoice
#### Scenario: Member with no invoices sees an empty state
- **WHEN** an authenticated member whose organization has no invoices opens the Billing page
- **THEN** the system displays a friendly empty state indicating there is no billing history yet, rather than an error or blank page
#### Scenario: Invoice list is scoped to the member's own organization
- **WHEN** the invoice list is rendered for a member
- **THEN** the system includes only invoices whose billing account belongs to that member's organization, and never invoices from any other organization
### Requirement: Member views invoice detail with line items and payments
The system SHALL provide a per-invoice detail view, addressable by invoice ID, headed "Invoice {number}" (the marker and tooltip when absent; Stripe's number as a secondary "Stripe invoice ..." line when the mapping carries one), that shows the period the invoice's lines pay for (Requirement: An invoice shows the period its lines pay for), the invoice's line items (description, quantity, amount) and the payments recorded against it (amount, status, date), each dated when it succeeded or failed (stripe-invoice-payment-projection, Requirement: A payment is dated when it succeeded or failed) and listed most recent first. The detail view SHALL only be accessible to a member of the organization that owns the invoice.
#### Scenario: Member opens an invoice they own
- **WHEN** an authenticated member selects an invoice belonging to their organization
- **THEN** the system displays the invoice's status, amounts, and the period its lines pay for, its line items with description, quantity, and amount, and any associated payments with amount, status, and date
#### Scenario: A payment's date is when it settled
- **WHEN** a member opens an invoice whose payment the console recorded a day after Stripe reported it paid
- **THEN** the payment's date is the day Stripe reports it paid
#### Scenario: Member requests an invoice that is not theirs
- **WHEN** an authenticated member requests an invoice whose billing account does not belong to their organization
- **THEN** the system responds with 404 Not Found and does not reveal whether the invoice exists
#### Scenario: Member requests a non-existent invoice
- **WHEN** an authenticated member requests an invoice ID that does not exist
- **THEN** the system responds with 404 Not Found
@@ -0,0 +1,32 @@
## MODIFIED Requirements
### Requirement: Operator invoice detail is addressable
The system SHALL provide a per-invoice detail view at `/operator/billing/invoices/{invoiceID}` headed "Invoice {number}" (the UUID muted beneath; Stripe's number, when the mapping carries one, shown as "Stripe invoice {stripe_invoice_number}", an external reference and never the heading), showing the invoice's period as "Period: {period}", the period its lines pay for (member-invoice-history, Requirement: An invoice shows the period its lines pay for), the invoice's line items (description, quantity, amount, period), amounts due and paid with correct minor-unit formatting, status including the derived Overdue presentation, due and paid dates, the linked organization (linked to its composite), and its Stripe sync state with the deep link when synced — reusing the member invoice-detail projection queries rather than a parallel read path. The page SHALL carry the standard back affordance to the invoices view (`chrome-conventions`), mark the Billing sidebar entry active, and answer an unknown invoice ID with the panel's ordinary 404 handling.
#### Scenario: Invoice detail renders the full projection
- **WHEN** an operator loads `/operator/billing/invoices/{invoiceID}` for a projected invoice
- **THEN** line items, amounts, status (with Overdue derived when applicable), dates, the linked organization, and Stripe sync state render
- **AND** a back affordance leads to the invoices view
#### Scenario: The invoice's period comes from its lines
- **WHEN** an operator opens the invoice of an upgrade made on Nov 10, 2026 from a monthly price to a yearly one, whose lines are a credit from Nov 10, 2026 to Nov 29, 2026 and a charge from Nov 10, 2026 to Nov 10, 2027, while Stripe's invoice-level period runs from Oct 29, 2026 to Nov 10, 2026
- **THEN** the page reads "Period: Nov 10, 2026 – Nov 10, 2027"
- **AND** each line's row shows that line's own period
#### Scenario: A credit of zero does not stretch the period
- **WHEN** an operator opens the invoice of an upgrade made on Nov 10, 2026 from a yearly price of $0 to a monthly one, whose lines are a line of zero from Nov 10, 2026 to Mar 1, 2027 and a charge from Nov 10, 2026 to Dec 10, 2026
- **THEN** the page reads "Period: Nov 10, 2026 – Dec 10, 2026"
#### Scenario: An invoice whose lines are all zero shows their period
- **WHEN** an operator opens the invoice of a price of $0, whose only line is of zero from Oct 29, 2026 to Nov 29, 2026
- **THEN** the page reads "Period: Oct 29, 2026 – Nov 29, 2026"
#### Scenario: Unknown invoice IDs 404 in the shell
- **WHEN** an operator loads the detail route with an ID no invoice has
- **THEN** the panel's ordinary 404 handling answers inside the operator shell
@@ -0,0 +1,153 @@
Tests follow `docs/testing.md`: database tests open the database through `dbtest`, build their rows through the test kit (`world`) or the package's fixtures, filter to the rows their own fixture made, and pass twice in a row without a reset. A page test reads the rendered HTML through `htmltest`, never by a substring of the whole body where a selector reaches the element.
Sections 1 to 4 are the parts of one commit; sections 5 and 6 are checks and landing, not commits. The commit passes `make test` with the test stack up, which runs `make lint` first and then every package one at a time (`-p 1`). The only goldens that change are the four demo seed goldens of item 2.2, edited by hand before the test runs, so the test passes without `-update`; any other golden, route table or existing test result that changes is a stop. A script's usage is learned from its header or source, never by running it.
**Breaks.** The list of item 3.4 is written to a file outside the repository and run with `go run ./scripts/mutation-check -list <file> -side new` on the commit (`-side new` applies each break to the commit's own code and runs the commit's tests), from the repository root, with `test/.env` loaded as `make test` loads it (without it the database tests skip and every record reports SKIPPED), after its green control run (a first run with no break applied, which must pass before any break is judged); the tests are new, so there is no old side (no earlier commit whose tests could run the same breaks). A break of Go code is a test record (`file`, `old`, `new`, `pkg`, `run_new`, `want_new`). A break caught by two tests is two records, one per test, with the same `file`, `old` and `new`. Every record's `want_new` is a text the failing test prints only when it fails, never a test or subtest name alone. Every record must be CAUGHT. An `old` text below is the code as the section's items write it; when the committed code words it differently, the record is corrected to the committed text before the run, never dropped, and its `new` text keeps the package compiling. A break that no test of the commit sees is named with the reason, and the commit message says so. The commit message lists every break with its result.
## 1. The period from the lines
- [x] 1.1 `invoicePeriodFromLines(lines []billing.InvoiceLineItem) (start, end sql.NullTime)` in `internal/server/member_invoices.go`, beside `formatInvoicePeriod` (`:164-178`), design D1. It first sets `charged` when any line has an amount above zero, whatever the line's period. It then skips a line when its amount is below zero, when `charged` is set and its amount is zero, when a bound of its period is NULL, or when its period's end does not follow its start. Of the lines left, it takes a line's start when no start is set yet or the line's start is earlier, and its end when no end is set yet or the line's end is later. With no line left it returns two invalid times. The body is written so each condition stands on a line of its own, and the breaks of item 3.4 name these lines:
```go
charged := false
for _, li := range lines {
if li.Amount > 0 {
charged = true
}
}
for _, li := range lines {
if li.Amount < 0 {
continue
}
if charged && li.Amount == 0 {
continue
}
if !li.PeriodStart.Valid || !li.PeriodEnd.Valid {
continue
}
if !li.PeriodEnd.Time.After(li.PeriodStart.Time) {
continue
}
if !start.Valid || li.PeriodStart.Time.Before(start.Time) {
start = li.PeriodStart
}
if !end.Valid || li.PeriodEnd.Time.After(end.Time) {
end = li.PeriodEnd
}
}
return start, end
```
Its doc comment states the rule and why each kind of line is left out: a credit returns time the invoice does not pay for; a line of zero beside a line above zero is the credit of an upgrade from a $0 price, and counting it would stretch the period to the end of that price's period, while on an invoice with no line above zero (a free price, a trial) the lines of zero are the only lines and show the period the invoice covers; a line whose period begins and ends at one moment is an invoice item given no period of its own, and the moment marks when it was added. It also says that Stripe's invoice-level period, which the invoice row holds, looks back one period on a subscription invoice and is never an invoice's period here.
- [x] 1.2 `formatInvoicePeriod`'s doc comment (`member_invoices.go:164-165`) says that it renders a period, an invoice's from `invoicePeriodFromLines` or one line's, as a range of dates, one date when one bound is set, and the empty-value marker when neither is. Its body is unchanged.
- [x] 1.3 `GetInvoiceLineItemsByInvoiceIDs :many` in `internal/billing/queries/invoice_line_items.sql`, with the comment and statement of design D4 (`SELECT * ... WHERE invoice_id = ANY(sqlc.arg(invoice_ids)::uuid[]) ORDER BY invoice_id, created_at ASC, line_item_id ASC`), and `make sqlc-generate`, which writes `GetInvoiceLineItemsByInvoiceIDs(ctx, invoiceIds []string) ([]InvoiceLineItem, error)` and its `Querier` method.
- [x] 1.4 `GetInvoices` (`member_invoices.go:198-264`), design D4: after the environment filter (`:248`) and only when `invoices` is not empty, it reads the lines of the listed invoices with `h.BillingQ.GetInvoiceLineItemsByInvoiceIDs(r.Context(), ids)` and files each line in `linesByInvoice` (`map[string][]billing.InvoiceLineItem`) under its `InvoiceID`. A failed read logs `failed to list invoice lines` at Error with the organization's id and the error, sets `data.Error = "Failed to load billing history"` and renders, as the reads at `:207-214` and `:218-225` do. Each row's `Period` (`:254`) is `formatInvoicePeriod(invoicePeriodFromLines(linesByInvoice[inv.InvoiceID]))`. The handler's doc comment adds that the list reads the listed invoices' lines in one statement for their periods.
- [x] 1.5 `GetInvoiceDetail` (`member_invoices.go:302-383`): the `Period` field leaves the literal at `:325`. In the error branch of the lines read (`:346`), `data.Period = formatInvoicePeriod(sql.NullTime{}, sql.NullTime{})` sets the empty-value marker beside "Failed to load invoice line items", so the page never derives an answer from a failed read; `data.Period = formatInvoicePeriod(invoicePeriodFromLines(lineItems))` follows the check.
- [x] 1.6 `loadOperatorInvoiceDetailData` (`internal/server/operator_billing.go:966-1033`): the same. The `Period` field leaves the literal at `:985`, and the error branch of the lines read (`:1017`) sets `data.Period = formatInvoicePeriod(sql.NullTime{}, sql.NullTime{})` before `data.Error`, while `data.Period = formatInvoicePeriod(invoicePeriodFromLines(lineItems))` follows the check. Its doc comment (`:958-965`) adds that the invoice's period comes from the lines it reads. The line rows (`:1023-1030`) keep each line's own period.
- [x] 1.7 `createInvoice` (`internal/integrations/stripe/workflows/invoice_reconcile.go:510-557`), design D3: a comment at `PeriodStart` and `PeriodEnd` (`:531-532`) says that they are Stripe's invoice-level period, the span in which the invoice's items were added, which looks back one period on a subscription invoice, and that no page shows it: each page shows the period the invoice's lines pay for (`invoicePeriodFromLines`, `internal/server/member_invoices.go`). No statement of the reconcile changes.
## 2. The demo seed
- [x] 2.1 `issue` (`internal/demoseed/demoseed.go:950-981`), design D5: it takes `invoiceFrom`, the start of Stripe's invoice-level period, before `periodStart, periodEnd, due`. The invoice row's period runs from `invoiceFrom` to `periodStart` (`PeriodStart: invoiceFrom, PeriodEnd: periodStart, DueDate: due,` in place of `:964`); the line's period (`:976`), the issue time (`:966`) and the due date stay. `issueDemoInvoices` (`:926-948`) passes Nov 1, 2025 for 0001, the purchase, whose invoice-level period begins and ends at its start; Nov 1, 2025 for 0002; and Dec 1, 2025 for 0003. `issue`'s doc comment says that the invoice row carries Stripe's invoice-level period, the purchase's beginning and ending at the purchase and a renewal's looking back one period, and that every page shows each invoice's period from its line. `demoInvoice`'s and `issueDemoInvoices`' doc comments are unchanged.
- [x] 2.2 The four goldens of `TestSeedBilling` (`internal/demoseed/seed_db_test.go:163-233`) that print Bob's invoice rows change in those rows only, edited by hand before the test runs. In `internal/demoseed/testdata/billing/first-run.txt:46-48`, `second-run.txt:30-32`, `price-exists.txt:45-47` and `subscription-exists.txt:45-47` the three rows become:
```
0001 | paid | 1000 | 1000 | usd | 2025-11-01T12:00:00Z | 2025-11-01T12:00:00Z | 2025-12-15T12:00:00Z | 2025-11-01T12:00:00Z | 2025-12-10T12:00:00Z
0002 | open | 1000 | 0 | usd | 2025-11-01T12:00:00Z | 2025-12-01T12:00:00Z | 2026-01-15T12:00:00Z | 2025-12-01T12:00:00Z | NULL
0003 | open | 1000 | 0 | usd | 2025-12-01T12:00:00Z | 2026-01-01T12:00:00Z | 2030-01-15T12:00:00Z | 2026-01-01T12:00:00Z | NULL
```
The `-- lines` rows, the statements and the logs of the four files stay.
## 3. The tests and their breaks
- [x] 3.1 `TestInvoicePeriodFromLines` (`internal/server/member_invoices_test.go`, package `server`), rows through a named function (`checkInvoicePeriodFromLines`). A row is a name, its lines in the order given (amount, start, end; a zero time is NULL) and the period it wants (a zero time is none). Times are 2026 at noon UTC unless the row says otherwise. Failure text `%s: the invoice's period is %s, want %s` (the row, then each period as `<start> to <end>` in RFC 3339, or `none`), compared by `Valid` and `Time.Equal` on each bound.
- `one subscription line`: 1000, Oct 29 to Nov 29; wants Oct 29 to Nov 29.
- `an upgrade's credit and charge`: −633, Nov 10 to Nov 29; 1267, Nov 10 to Nov 29; wants Nov 10 to Nov 29.
- `an upgrade to a yearly price`: −633, Nov 10 to Nov 29; 30000, Nov 10, 2026 to Nov 10, 2027; wants Nov 10, 2026 to Nov 10, 2027.
- `an upgrade to a monthly price`: −21000, Nov 10, 2026 to Mar 1, 2027; 4000, Nov 10 to Dec 10; wants Nov 10 to Dec 10.
- `prorations folded into a renewal`: −300, Nov 20 to Nov 29; 600, Nov 20 to Nov 29; 2000, Nov 29 to Dec 29; wants Nov 20 to Dec 29.
- `an upgrade from a free yearly price`: 0, Nov 10, 2026 to Mar 1, 2027; 4000, Nov 10 to Dec 10; wants Nov 10 to Dec 10.
- `a free price`: 0, Oct 29 to Nov 29; wants Oct 29 to Nov 29.
- `a trial`: 0, Oct 29 to Nov 12; wants Oct 29 to Nov 12.
- `a one-off item`: 1500, Oct 29 at 10:00 UTC to the same moment; wants none.
- `a trial beside a one-off fee`: 0, Oct 29 to Nov 12; 1500, Oct 29 at 10:00 UTC to the same moment; wants none (C18).
- `a one-off item beside a renewal`: 1500, Nov 20 to the same moment; 2000, Nov 29 to Dec 29; wants Nov 29 to Dec 29.
- `credits only`: −500, Nov 10 to Nov 29; wants none.
- `a line with no start`: 1000, NULL to Nov 29; wants none.
- `no lines`: no line; wants none.
`one subscription line`, `an upgrade's credit and charge`, `an upgrade to a yearly price` and `no lines` are guard rows: they pin the cases the issue names. Only the break that drops `!start.Valid ||` changes the result of the first three (a start never set reads none), and no break changes the result of `no lines`.
- [x] 3.2 `TestInvoicePagesShowThePeriodTheirLinesPayFor` (a new `internal/server/invoice_period_db_test.go`, package `server`), database-backed. The fixture, through `world`: an organization of type `personal` with its owner, one billing account, and one price; four invoices on the account, each with the number given, its row's period as Stripe sends it and its issue time, and its lines through `w.LineItem`; times are noon UTC.
| Number | Row's period (Stripe's) | Issued | Lines | Wanted |
|---|---|---|---|---|
| 0001 (C1) | Oct 29, 2026 to Oct 29, 2026 | Oct 29, 2026 | 1000, Oct 29, 2026 to Nov 29, 2026 | "Oct 29, 2026 – Nov 29, 2026" |
| 0002 (C2) | Oct 29, 2026 to Nov 29, 2026 | Nov 29, 2026 | 1000, Nov 29, 2026 to Dec 29, 2026 | "Nov 29, 2026 – Dec 29, 2026" |
| 0003 (C4) | Nov 29, 2026 to Dec 10, 2026 | Dec 10, 2026 | −645, Dec 10, 2026 to Dec 29, 2026; 30000, Dec 10, 2026 to Dec 10, 2027 | "Dec 10, 2026 – Dec 10, 2027" |
| 0004 (C12) | Dec 10, 2026 to Dec 12, 2026 | Dec 12, 2026 | none | "—" |
The member's handler is built with `NewMemberInvoicesHandler` on `billing.New(db)` and a session of the owner with the organization's id; the operator's with `NewOperatorPartialsHandler` and an operator session, as `TestGetOperatorInvoiceDetailPage` builds them (`operator_billing_invoice_detail_test.go:41-72`). Four subtests report with `t.Errorf`; the first three check the four invoices in number order:
- `member list`: `GetInvoices`; the row whose `th` reads the number, and its first `td`. Failure texts `the list has no row %s:\n%s` and `the list's row %s reads period %q, want %q`.
- `member invoice page`: `GetInvoiceDetail` for each invoice; the period beside the status badge (`member_invoice_detail.html:19`). Failure text `the member page of invoice %s reads period %q, want %q`.
- `operator invoice page`: `GetOperatorInvoiceDetailPage` for each invoice; the paragraph that begins `Period:` (`operator_billing_invoice_detail.html:61`), whose text is wanted as `Period: ` and the period. Failure text `the operator page of invoice %s reads %q, want %q`.
- `operator line rows`: `GetOperatorInvoiceDetailPage` for invoice 0003; the period cell of each row of the line table (`operator_billing_invoice_detail.html:87`, the `td` with class `text-nowrap`), in order, wanted as "Dec 10, 2026 – Dec 29, 2026" for the credit and "Dec 10, 2026 – Dec 10, 2027" for the charge. Failure text `the operator page's line %d of invoice 0003 reads period %q, want %q`.
Each row's period as the invoice row holds it differs from the wanted text, so a page that reads the row fails on every invoice.
- [x] 3.3 `TestMemberInvoiceListShowsTheLoadErrorWhenItsLinesCannotBeRead` (`internal/server/member_invoices_test.go`): a querier that embeds `billing.Querier` and answers `ListBillingAccountsByOrgID` with one account, `GetInvoicesByBillingAccountID` with one issued invoice numbered `0001`, and `GetInvoiceLineItemsByInvoiceIDs` with an error; the handler from `NewMemberInvoicesHandler` with that querier and no Stripe querier, and a signed-in session carrying the organization's id. `GetInvoices` renders the alert reading "Failed to load billing history" and no row: failure text `a list whose lines cannot be read rendered:\n%s\nwant the load error and no row`.
`TestInvoicePagesShowNoPeriodWhenTheirLinesCannotBeRead` (in `internal/server/invoice_period_db_test.go`), database-backed, pins F2 and F3. A wrapper type embeds `billing.Querier` and answers `GetInvoiceLineItemsByInvoiceID` with an empty slice and `errReadFailed` (`member_plans_offer_db_test.go:73`), every other method going to the embedded querier, in the shape of `failingFeedBillingQ` (`internal/server/operator_activity_failures_db_test.go:31`). The fixture, through `world`: one organization of type `personal` with its owner, one billing account, one invoice numbered `0001` whose row's period is Oct 29, 2026 to Oct 29, 2026, with one line from Oct 29, 2026 to Nov 29, 2026; both handlers are built as item 3.2 builds them, with `BillingQ` set to the wrapper around `billing.New(database)`. Two subtests, each reading the page and reporting with `t.Errorf`:
- `member invoice page`: `GetInvoiceDetail`; the period beside the status badge is wanted as "—". Failure text `the member page of an invoice whose lines cannot be read reads period %q, want %q`.
- `operator invoice page`: `GetOperatorInvoiceDetailPage`; the paragraph that begins `Period:` is wanted as `Period: —`. Failure text `the operator page of an invoice whose lines cannot be read reads %q, want %q`.
- [x] 3.3a `TestMemberInvoiceListReadsItsLinesInOneStatement` (`internal/server/invoice_period_db_test.go`), database-backed: three invoices with one line each, the member's handler over `periodCountingQ`, a querier that embeds `billing.Querier` and counts the calls of `GetInvoiceLineItemsByInvoiceIDs` and of `GetInvoiceLineItemsByInvoiceID`; `GetInvoices` makes one batch call and no per-invoice call. Failure text `the list of three invoices read their lines in %d batch statements and %d per-invoice statements, want 1 and 0`.
- [x] 3.4 Breaks:
- credits counted (the record's `old` is `li.Amount < 0`, its `new` `false`): caught by `TestInvoicePeriodFromLines/an_upgrade_to_a_monthly_price`, `want_new` `an upgrade to a monthly price: the invoice's period is`, and by `TestInvoicePeriodFromLines/credits_only`, `want_new` `credits only: the invoice's period is`;
- zero-amount lines left out (`li.Amount < 0` to `li.Amount <= 0`): caught by `TestInvoicePeriodFromLines/a_trial`, `want_new` `a trial: the invoice's period is`, and by `TestInvoicePeriodFromLines/a_free_price`, `want_new` `a free price: the invoice's period is`;
- a line of zero counted beside a charge (the record's `old` is `if charged && li.Amount == 0 {`, its `new` `if false {`): caught by `TestInvoicePeriodFromLines/an_upgrade_from_a_free_yearly_price`, `want_new` `an upgrade from a free yearly price: the invoice's period is`;
- a line of zero never counted (`if charged && li.Amount == 0 {` to `if li.Amount == 0 {`): caught by `TestInvoicePeriodFromLines/a_free_price`, `want_new` `a free price: the invoice's period is`, and by `TestInvoicePeriodFromLines/a_trial`, `want_new` `a trial: the invoice's period is`;
- a line of zero taken for a charge (`if li.Amount > 0 {` to `if li.Amount >= 0 {`): caught by `TestInvoicePeriodFromLines/a_free_price`, `want_new` `a free price: the invoice's period is`, and by `TestInvoicePeriodFromLines/a_trial`, `want_new` `a trial: the invoice's period is`;
- the first start lost (`if !start.Valid || li.PeriodStart.Time.Before(start.Time) {` to `if li.PeriodStart.Time.Before(start.Time) {`): a start never set reads none, so it is caught by `TestInvoicePeriodFromLines/one_subscription_line`, `want_new` `one subscription line: the invoice's period is`;
- a period of one moment counted (`!li.PeriodEnd.Time.After(li.PeriodStart.Time)` to `false`): caught by `TestInvoicePeriodFromLines/a_one-off_item`, `want_new` `a one-off item: the invoice's period is`, and by `TestInvoicePeriodFromLines/a_one-off_item_beside_a_renewal`, `want_new` `a one-off item beside a renewal: the invoice's period is`;
- a line with no start counted (`!li.PeriodStart.Valid` to `false`): caught by `TestInvoicePeriodFromLines/a_line_with_no_start`, `want_new` `a line with no start: the invoice's period is`;
- the latest start kept (`li.PeriodStart.Time.Before(start.Time)` to `li.PeriodStart.Time.After(start.Time)`): caught by `TestInvoicePeriodFromLines/prorations_folded_into_a_renewal`, `want_new` `prorations folded into a renewal: the invoice's period is`;
- the earliest end kept (`li.PeriodEnd.Time.After(end.Time)` to `li.PeriodEnd.Time.Before(end.Time)`): caught by `TestInvoicePeriodFromLines/prorations_folded_into_a_renewal`, `want_new` `prorations folded into a renewal: the invoice's period is`;
- the list reading the invoice row (`formatInvoicePeriod(invoicePeriodFromLines(linesByInvoice[inv.InvoiceID]))` to `formatInvoicePeriod(inv.PeriodStart, inv.PeriodEnd)`): caught by `TestInvoicePagesShowThePeriodTheirLinesPayFor/member_list`, `want_new` `the list's row 0001 reads period`;
- the list filing every line under every listed invoice (the record's `old` is the grouping's append, `linesByInvoice[li.InvoiceID] = append(linesByInvoice[li.InvoiceID], li)`, its `new` a loop over `invoices` that appends the line under each one's id): caught by `TestInvoicePagesShowThePeriodTheirLinesPayFor/member_list`, `want_new` `the list's row 0001 reads period`;
- the member's invoice page reading the invoice row (in `member_invoices.go`, `data.Period = formatInvoicePeriod(invoicePeriodFromLines(lineItems))` to `data.Period = formatInvoicePeriod(invoice.PeriodStart, invoice.PeriodEnd)`): caught by `TestInvoicePagesShowThePeriodTheirLinesPayFor/member_invoice_page`, `want_new` `the member page of invoice 0001 reads period`;
- the operator's invoice page reading the invoice row (the same edit in `operator_billing.go`): caught by `TestInvoicePagesShowThePeriodTheirLinesPayFor/operator_invoice_page`, `want_new` `the operator page of invoice 0001 reads`;
- the list ignoring a failed read of lines (the record's `old` is the lines read and the `if err != nil {` that follows it, its `new` the same with `if false && err != nil {`): caught by `TestMemberInvoiceListShowsTheLoadErrorWhenItsLinesCannotBeRead`, `want_new` `a list whose lines cannot be read rendered`;
- an invoice page showing no marker after a failed read of its lines, member's (in `member_invoices.go`, the record's `old` is `data.Period = formatInvoicePeriod(sql.NullTime{}, sql.NullTime{})`, its `new` `data.Period = ""`): caught by `TestInvoicePagesShowNoPeriodWhenTheirLinesCannotBeRead/member_invoice_page`, `want_new` `the member page of an invoice whose lines cannot be read reads period`;
- the same for the operator's page (the same edit in `operator_billing.go`): caught by `TestInvoicePagesShowNoPeriodWhenTheirLinesCannotBeRead/operator_invoice_page`, `want_new` `the operator page of an invoice whose lines cannot be read reads`;
- the list falling back to the invoice row when no line counts (in `member_invoices.go`, `old` `formatInvoicePeriod(invoicePeriodFromLines(linesByInvoice[inv.InvoiceID]))`, `new` `formatInvoicePeriod(func() (sql.NullTime, sql.NullTime) {\n\t\t\t\tif s, e := invoicePeriodFromLines(linesByInvoice[inv.InvoiceID]); s.Valid || e.Valid {\n\t\t\t\t\treturn s, e\n\t\t\t\t}\n\t\t\t\treturn inv.PeriodStart, inv.PeriodEnd\n\t\t\t}())`): caught by `TestInvoicePagesShowThePeriodTheirLinesPayFor/member_list`, `want_new` `the list's row 0004 reads period`;
- the member's invoice page falling back to the invoice row when no line counts (in `member_invoices.go`, `old` `data.Period = formatInvoicePeriod(invoicePeriodFromLines(lineItems))`, `new` `if s, e := invoicePeriodFromLines(lineItems); s.Valid || e.Valid {\n\t\tdata.Period = formatInvoicePeriod(s, e)\n\t} else {\n\t\tdata.Period = formatInvoicePeriod(invoice.PeriodStart, invoice.PeriodEnd)\n\t}`): caught by `TestInvoicePagesShowThePeriodTheirLinesPayFor/member_invoice_page`, `want_new` `the member page of invoice 0004 reads period`;
- the operator's invoice page falling back to the invoice row when no line counts (the same `old` and `new` in `operator_billing.go`): caught by `TestInvoicePagesShowThePeriodTheirLinesPayFor/operator_invoice_page`, `want_new` `the operator page of invoice 0004 reads`;
- the list reading each invoice's lines in its own statement (in `member_invoices.go`, `old` `lines, err := h.BillingQ.GetInvoiceLineItemsByInvoiceIDs(r.Context(), ids)`, `new` a loop over `ids` that calls `GetInvoiceLineItemsByInvoiceID` and appends): caught by `TestMemberInvoiceListReadsItsLinesInOneStatement`, `want_new` `the list of three invoices read their lines in`;
- the operator's line rows showing the invoice row's period (in `operator_billing.go`, the record's `old` is `Period: formatInvoicePeriod(li.PeriodStart, li.PeriodEnd),` with its six spaces after the colon, its `new` `Period: formatInvoicePeriod(invoice.PeriodStart, invoice.PeriodEnd),`): caught by `TestInvoicePagesShowThePeriodTheirLinesPayFor/operator_line_rows`, `want_new` `the operator page's line 1 of invoice 0003 reads period`;
- the demo seed writing the line's period on the invoice row again (`PeriodStart: invoiceFrom, PeriodEnd: periodStart, DueDate: due,` to `PeriodStart: periodStart, PeriodEnd: periodEnd, DueDate: due,`, `pkg` `./internal/demoseed`): caught by `TestSeedBilling/first-run`, `want_new` `testdata/billing/first-run.txt differs from what the code produced`.
Two edits have no break, and the commit message names them with the reason. Dropping `!li.PeriodEnd.Valid` is equivalent: a NULL end reads as the zero time, which is never after a set start, and a line with a NULL start is skipped by the start's check first. Dropping `!end.Valid ||` from the end's condition is equivalent too: with no end set, `end.Time` is the zero time, and every set end is after it. (The same drop on the start's condition is not equivalent, because no set start is before the zero time; it has the break above.)
- [x] 3.5 Existing tests keep their results: `TestFormatInvoicePeriod` (the function is unchanged); `TestMemberInvoiceTemplatesRender` and the tests of `invoice_number_render_test.go` (they render view models); `TestGetOperatorInvoiceDetailPage` (its line's period equals its row's, and it reads no period); `TestMemberInvoicesListedByIssueAcrossAccounts` (`billing_times_db_test.go`), the hosted invoice tests (`member_hosted_invoice_db_test.go`) and the environment tests (`operator_billing_environment_db_test.go`), which seed invoices without lines and read no period; and the invoice reconcile tests, whose writes do not change.
## 4. The documents
- [x] 4.1 `docs/stripe.md:129`, "Invoices and payments", design D6, becomes: "An invoice's period, on the Billing page, the member's invoice page and the operator's invoice page, is the period its lines pay for: from the earliest start to the latest end of its stored lines, leaving out credits (a negative amount, such as the unused time an upgrade returns), a line of zero beside a line above zero (such as the credit of an upgrade from a $0 price), and lines whose period is a single moment (an invoice item given no period of its own). An invoice whose lines are all zero, such as one for a free price or a trial, shows their period. An invoice with no such line shows no period. Each line carries the service period it bills: a subscription line the subscription's period, a proration the time from the change to the end of the period, an invoice item the moment it was created unless it was given a period. The console also stores Stripe's invoice-level `period_start` and `period_end`, the span in which the invoice's items were added, and shows it nowhere: on a subscription invoice it looks back one period, so a renewal's is the period that just ended and a purchase's begins and ends at the purchase."
- [x] 4.2 `docs/models/payments-billing.md:30`, the row of the money tables, gains after "with the id breaking ties": "An invoice's `period_start` and `period_end` hold Stripe's invoice-level period, which looks back one period on a subscription invoice; every page shows an invoice's period from its lines instead ([stripe.md](../stripe.md#invoices-and-payments))."
- [x] 4.3 No other document changes: no migration, so `docs/database-management.md` and the reader role's section are untouched, and no test kit part changes, so `docs/testing.md` is untouched.
## 5. Screens
- [x] 5.1 With the test stack up, `make screens` reports no changed screen. `member-billing` shows Bob's three demo invoices, and `operator-billing-invoice-detail` shows invoice 0003; each line carries the period the seed wrote on its invoice row before this change, so each page shows the same text. A changed screen is a stop. On a database seeded before this change the seed leaves Bob's invoices alone (`demoseed.go:820-825`), so its rows keep the old periods until the demo database is recreated; the pages read the same either way.
- [x] 5.2 No hand capture. The one page outside the manifest that shows an invoice's period, the member's invoice page, shows the same text for every demo invoice, and no page's layout changes. Item 3.2 pins the new text.
## 6. Landing
- [x] 6.1 With the test stack up, `make test` passes (it runs `make lint` first), and `openspec validate invoice-shows-the-period-it-pays-for --strict` passes.
- [x] 6.2 The break list of item 3.4 reads CAUGHT in the commit message, with the two edits that have no break named and their reasons.
- [ ] 6.3 After the push, post on #293 under `CONTRIBUTING.md`'s rules for forge text (item 1: it reads for someone who has never opened the repository, explains the console's terms once, and names no decision number, change name, date as narrative, deployment or count), and close it. Draft:
> The member's Billing page, the member's invoice page and the operator's invoice page now show the period an invoice's lines pay for. Until now they showed the period Stripe sets on the invoice itself, which Stripe defines as the span in which the invoice's items were added and which looks back one period on a subscription invoice: a purchase's began and ended on the day of the purchase, and a renewal's was the month that had just ended. Each line of an invoice carries the service period it bills, and the console already stores the lines it reads back from Stripe. An invoice's period now runs from the earliest start to the latest end of its stored lines, leaving out credits (a negative line, such as the unused time an upgrade returns, which the invoice does not pay for), a line of zero beside a charge (such as the credit of an upgrade from a $0 price) and lines whose period is a single moment (an invoice item given no period of its own); an invoice whose lines are all zero, such as one for a free price, shows their period. A monthly plan bought on Oct 29, 2026 reads "Oct 29, 2026 – Nov 29, 2026", and an upgrade to a yearly price reads the year that starts at the upgrade. An invoice with no such line, such as one whose lines all use prices the console did not create, shows "—", the console's mark for a value it does not have. The pages compute the period each time they are read, so every invoice already stored shows it, with no migration. The console still stores Stripe's own invoice period and shows it nowhere. The demo database's invoices now carry Stripe's invoice period beside their lines, as a real deployment's do. Tests check the rule case by case and the period on each of the three pages.
>
> Commit: …
- [ ] 6.4 The archive waits for the maintainer's go; the deltas carry the spec changes, and no Purpose needs an edit a delta cannot make.