Mark a subscription that delivers nothing with a badge, on the organization page and in the subscriptions list

Section 8 of #212's change, after review: the Billing card's muted line
under the Subscriptions row becomes an amber "Not delivering" badge on
the row's value line, after the state badge, with a title that names
the ladders and the holder ("Billing continues while a grant holds Demo
Ladder."; with several subscriptions "Billing continues on <k> of <n>
subscriptions while ..."). The operator's subscriptions list marks each
displaced subscription's row with the same badge and title, read with
one grouped statement per page and turned into titles by the same steps
the card uses. A failed read on the list says "Failed to load
deliveries." once above the table and marks no row. A paused
subscription no longer counts: it generates no invoice, so "Billing
continues" would be false; it is marked once it is active again.

The grant form's warning now reads "Products marked ⚠ would supersede a
tier a subscription pays for; the subscription keeps billing and
delivers nothing on that ladder until the grant ends." The billing view
it named before can change nothing. A Stripe-paused subscription's tier
is still marked, an accepted edge until #269's change, which pauses a
displaced subscription's billing and rewords the badge and the warning.

Goldens: 16 change, each as item 8.9 lists: the organization page's
JSON goldens rename DisplacementNote to DisplacementTitle, the two
subscriptions-list goldens gain the title field, three HTML goldens
carry the new warning, and grant-over-subscription.html the badge in
place of the line. No other golden changed.

Breaks (scripts/mutation-check, 31 of 31 CAUGHT):
- paused restored
- not_delivering removed from badgeMap
- badge tone
- list badge without title
- card title never set
- card template without badge
- account form never chosen
- n read as displaced count
- subscription holder as grant
- several holders as one
- ladders joined with commas
- sort removed
- ladders not deduplicated
- holders not deduplicated
- status filter dropped (card)
- status filter dropped (list)
- list row in account form
- list row in account form (list vs card)
- list titles never set (the field set to strings.TrimPrefix of the
  title by itself, an empty string, because removing the line leaves
  titles unused and does not compile)
- delivering row given the displaced title
- list template without badge
- list pool not default
- list reads moved into the row loop (emulated: an inline closure calls
  the title read once per row; a helper only the break called would fail
  deadcode)
- list DisplacementError assignment and L4 removed
- O4 template line removed
- O4 line moved under the table
- rows dropped on a failed read
- badge label
- card badge without title
- delivering row given an entry
- old warning restored

Hand capture on the Stripe sandbox (API 2025-02-24.acacia): a member
subscribed to a $20/month tier through Checkout; an operator issued a
complimentary grant of the tier above; an edit to the subscription at
Stripe then reconciled it, and the reconcile logged "reconcile: product
not conferred; another plan holds its ladder". The Billing card and the
subscription's list row each showed Active and Not delivering at 1280
and at the 390 mobile width, with no sideways page scroll; the badge's
title read by script: "Billing continues while a grant holds Demo
Ladder." Captures under test/screens/displaced-subscription-waits/.

The suite on reset databases passed on this section's tree, 4711 tests,
14 skipped; make screens changed no screen this section owns, and every
registered form was captured. The review's last fixes (the list test's
row checks now fail when a row is missing, two comment rewraps, task
wording) came after it; the list test and its five breaks were rerun on
them: pass, 5 of 5 CAUGHT. make lint passes; openspec validate --strict
passes.
This commit is contained in:
2026-10-26 17:38:12 -05:00
parent 701ab7e5b9
commit 2f1803d1e6
42 changed files with 1007 additions and 222 deletions
+2 -2
View File
@@ -34,7 +34,7 @@ Database objects sit in the `core` schema; the baseline DDL is `internal/db/migr
| `core.product_conferral_shapes` | The view conferral reads for a product's ladder positions and availability facts (introduced in the Product Catalog card) | `00004_doc41_schema_and_data.sql` |
| `core.supersession_pairs` | The one statement of a supersession: an `end` row and the typed row with a from side of the conferral that caused it, in the same pool, on the same ladder, at the same `effective_at`, by the same actor, whatever the two provisions' sources; reads no `reason` | `00033_supersession_pairs.sql` |
| `core.grant_replacements` | A view of which source replaced a grant: it reads its pairs from `core.supersession_pairs` and keeps those whose ended provision is a grant's and whose successor is the grant or subscription that took the rung, leaving out extensions and a grant's own re-conferral | `00023_grant_resumption.sql`; `00033_supersession_pairs.sql` |
| `core.subscription_displacements` | The ladders of a pool on which a subscription is displaced and another plan holds the ladder (invariant 20): per ladder, the product the subscription lost, the instant, and the holder's provision and source. Displaced means the subscription's latest episode on the ladder ended in a supersession (a pair in `core.supersession_pairs`) whose successor belongs to another subscription or to no subscription; held means an active or suspended position of a non-default source other than the subscription sits on the ladder. The subscription reconcile and the organization page read it; it reads no `reason` | `00034_subscription_displacements.sql` |
| `core.subscription_displacements` | The ladders of a pool on which a subscription is displaced and another plan holds the ladder (invariant 20): per ladder, the product the subscription lost, the instant, and the holder's provision and source. Displaced means the subscription's latest episode on the ladder ended in a supersession (a pair in `core.supersession_pairs`) whose successor belongs to another subscription or to no subscription; held means an active or suspended position of a non-default source other than the subscription sits on the ladder. The subscription reconcile, the organization page and the operator's subscriptions list read it; it reads no `reason` | `00034_subscription_displacements.sql` |
| `core.transition_change_kind` | The kind of change one transition records, which the Tier changes Change badge and the recent-activity line name: `resumption` for a resumption's start, `extension` for an extension's transfer or a legacy `extend` row, otherwise the row's type. A machine reason counts only from the writer that writes it, because Issue grant copies the operator's note into the reason | `00025_superseded_transitions.sql` |
| `core.superseded_ends` | A view of the `end` rows a typed row of the same act states: an `end` and a typed row with a from side in the same pool, at the same `effective_at`, by the same actor, with the same reason, on any ladder, since supersession ends every rung of the incumbent. The Tier changes list leaves out every one; the recent-activity feed leaves out only those an extension's transfer states (`by_extension`). A grant that a plan act creates already points at its act in `core.grants.act_id` (the [Acts card](acts.md)), but a transition row has no such column, so the view still matches the rows by pool, instant, actor and reason. When the writers of transitions record their act (#179), the view reads that pointer and only its body changes. The reader role cannot read it, since it reads the reason | `00025_superseded_transitions.sql` |
| `core.extension_handoffs` | A view of the transitions that record an extension, each with the grant it handed the position to. The recent-activity feed leaves those grants' rows out: the transfer, which reads "Extended", is the extension's one row. The reader role cannot read it, since it reads the reason | `00025_superseded_transitions.sql` |
@@ -77,7 +77,7 @@ An invariant is a rule the model guarantees everywhere; code that would break on
17. **[app]** Every plan act's transition reason comes from one function, `PlanActReason` (`internal/entitlements/plan_act_runner.go`), which builds it from the act's detail row so the wording lives in one place. The three tier acts that write transitions (add, removal, reorder) name the ladder: `tier added to ladder <name>`, `tier removal (<product> from <ladder>)` and `plan-ladder tier reorder (<ladder>)`. The default change names the org type's key: `org-type default change (<org type>)`. `DeleteTier` records its act (cause `tier_removed`) but writes no transition, because no one holds the tier, so it builds no reason; a Retry moves no position and has no reason. A move that a drain settles later builds the reason from the original act's detail row, so it reads as the act's own, and a migrated or grandfathered position appends what was done to it (`MigratedReason`, `GrandfatheredReason`). The reason is stored on the transition rows, so it is a write-time snapshot: it records the name as it read then, and a later rename does not rewrite it. An act that cannot read the ladder's row refuses at the read of names and records nothing.
18. **[app]** When a subscription's end, a grant's revocation or a grant's expiry ends at least one provision of that plan, every grant `core.resumable_grants(now())` names with that plan as holder re-confers from its own row, with the reason `grant-resumption:subscription:`, `grant-resumption:revocation:` or `grant-resumption:expiration:` followed by the ending plan's id. The chain rule: a marked grant waits on the nearest plan above it in its chain of replacements that has not ended, and resumes when that plan ends; a plan between them that ends first (it expires, is revoked, or is unmarked while it waits) is passed over. A grant and its extensions are one plan, named by the lineage's head. `core.grant_waits` is the one statement of the walk and the conditions (marked, active, valid at the instant, delivering nothing, its product on at least one ladder whose rungs no source but the holder holds, and first in resumption order among grants sharing a ladder), and the operator's delivery states and the member's held rung read it through the two functions. No other ending path resumes a grant: not a suspension, not a subscription dropping an item, not an extension, not an org-type migration and not a tier removal.
19. **[app]** On a ladder of the product it buys or switches to, a member's own move never takes a pool below a rung a grant delivers: from a grant-held rung the only move offered is up, through Checkout. Checkout never supersedes a position on another ladder of the product unless that ladder offers Checkout too: Checkout of a product refuses unless every ladder it is a tier of offers it. Two exceptions are open (#88), because the decision reads a position only on the ladders it is asked about. The plan switch: a switch to a product that is a tier of several ladders can supersede a position on another of them, a grant's included, because deciding it needs the subscription that holds each ladder. And a rung on a ladder the product is not a tier of: supersession is whole-bundle (invariant 7), so Checkout that supersedes a grant's position on a ladder of the product also ends that grant's rungs on ladders the product is not a tier of, and no default is restored there while the pool holds the product (invariant 11). `plans.Offer` and `plans.Across` state the rule; the Products page, Checkout and the switch read it.
20. **[db-code]** and **[app]** A subscription's reconcile does not take back a ladder another plan took from it. While a position of a non-default source other than the subscription holds a ladder on which the subscription's latest episode ended in a supersession by another source, the reconcile confers none of the subscription's products whose shape names that ladder; the subscription keeps billing and delivers nothing there, and its next reconcile after no such plan holds the ladder confers it again. A default-sourced position never holds a ladder against a subscription (invariant 11). `core.subscription_displacements` is the one statement of which ladders those are; the reconcile and the organization page read it.
20. **[db-code]** and **[app]** A subscription's reconcile does not take back a ladder another plan took from it. While a position of a non-default source other than the subscription holds a ladder on which the subscription's latest episode ended in a supersession by another source, the reconcile confers none of the subscription's products whose shape names that ladder; the subscription keeps billing and delivers nothing there, and its next reconcile after no such plan holds the ladder confers it again. A default-sourced position never holds a ladder against a subscription (invariant 11). `core.subscription_displacements` is the one statement of which ladders those are; the reconcile, the organization page and the operator's subscriptions list read it.
## Dimensions
+2 -2
View File
@@ -154,14 +154,14 @@ An operator can issue a grant to move a pool to a specific tier:
1. Open the organization's page from the **Organizations** list.
2. In the **Grants** section, click **Issue grant**. The panel opens with a stacked form: one field per row, each label above its field.
3. Choose a **Product**, optionally a **Quantity** and a **Valid until** timestamp, choose a **Reason**, and optionally add a **Note**.
3. Choose a **Product**, optionally a **Quantity** and a **Valid until** timestamp, choose a **Reason**, and optionally add a **Note**. A product whose grant would supersede a tier a subscription holds, active or suspended, is marked ⚠ in the **Product** list, and the form's warning says that the subscription keeps billing and delivers nothing on that ladder until the grant ends.
4. Tick **Resumes**, whose label sits above its box, when the grant should deliver again after a plan bought or granted over it ends (see [Grants that resume](#grants-that-resume)). It starts unticked, and it is offered for every product, including the organization type's default product.
5. Click **Issue grant**.
The system:
- Creates a grant with the chosen reason and the resumption mark, then calls the conferral boundary (`internal/entitlements/conferral.go`) through the operator handler in `internal/server/operator_enrollment.go`: it writes the grant decree, confers the position onto the organization's default pool, then materializes the organization's effective entitlements
- Registers a **Temporal workflow** to expire the grant at `valid_until` when one is set
- Keeps the ladder for a grant that supersedes a tier a subscription pays for: the subscription keeps billing and delivers nothing there while the grant lasts, and the organization page's Billing card reads "Delivers nothing on {ladder}, which a grant holds" under the subscription; the subscription delivers again at its next reconcile after the grant ends
- Keeps the ladder for a grant that supersedes a tier a subscription pays for: the subscription keeps billing and delivers nothing there while the grant lasts, and the organization page's Billing card and the subscription's row in the operator's subscriptions list carry a **Not delivering** badge whose title reads "Billing continues while a grant holds {ladder}."; the subscription delivers again at its next reconcile after the grant ends
While Temporal is not connected, which on a deployment without
`temporal-host` is always, a grant with **Valid until** is refused before
+1 -1
View File
@@ -211,7 +211,7 @@ A member moves between the paid tiers of a plan ladder on the Products page, and
- **Cancellation.** Every paid plan offers **Cancel plan** on its card. Access continues until the end of the paid period (`cancel_at_period_end`), after which the organization moves to its type's default plan, when one is configured, or leaves the ladder.
- **Taking a scheduled move back.** While a downgrade or a cancellation is scheduled, the plan's card offers **Keep plan**, which releases the schedule or clears `cancel_at_period_end` at no charge. While a downgrade is scheduled, **Cancel plan** follows it and replaces the downgrade; while a cancellation is scheduled, **Keep plan** is the only control.
- **A plan whose payment is due.** A subscription Stripe holds as `past_due` or `unpaid` keeps its card on the Products page, shown as the current plan with its state, while its entitlements are suspended. No switch is offered in either direction and a posted one is refused before any Stripe call, because a switch would invoice while the failed invoice is still open. **Keep plan** and **Cancel plan** at the period end work as on an active plan. A posted immediate cancellation is refused, because Stripe's immediate cancellation stops automatic collection of the customer's finalized invoices, and a **Keep plan** over a downgrade that Stripe has already applied is refused, because there is nothing left to take back. The unpaid invoice stays due after a cancellation: once the cancellation takes effect, Stripe stops retrying that subscription's open invoice and leaves the customer's other subscriptions collecting, so the member pays it through the invoice's **Pay invoice**. A paused subscription shows its state and is refused every act; only a subscription made outside the console reaches `paused`, which follows a trial that ends without a payment method, because the console's Checkout creates no trial.
- **A grant over a paid plan.** An operator's grant over a tier a subscription pays for keeps the ladder until it ends or is revoked. The subscription keeps billing and delivers nothing there, and the console changes nothing at Stripe. While the grant holds the ladder, the organization page's Billing card reads "Delivers nothing on {ladder}, which a grant holds" under the subscription. Once only the organization type's default holds the ladder, or nothing does, the subscription's next reconcile delivers its tier again. A new purchase over a grant still replaces the grant.
- **A grant over a paid plan.** An operator's grant over a tier a subscription pays for keeps the ladder until it ends or is revoked. The subscription keeps billing and delivers nothing there, and the console changes nothing at Stripe. While the grant holds the ladder, the organization page's Billing card and the subscription's row in the operator's subscriptions list carry a **Not delivering** badge, whose title reads "Billing continues while a grant holds {ladder}." Once only the organization type's default holds the ladder, or nothing does, the subscription's next reconcile delivers its tier again. A new purchase over a grant still replaces the grant.
- **Pay invoice.** The card of a plan whose payment is due, and the detail of an open invoice, link to Stripe's hosted invoice page. The console reads the page's URL from Stripe when the member opens the link and redirects there; it never stores or logs the URL, which is a bearer link, and it needs no key permission beyond the reads of subscriptions and invoices that the reconciles already make. Paying there makes the new card the customer's default for invoices but leaves the subscription's own default on the card that failed, which Stripe charges first, so the next renewal charges the failed card again. Stripe's page still takes payment for an invoice Stripe has marked uncollectible, which the console shows as open until the invoice sweep reads it again.
A restricted key needs permission for the calls these moves make, beyond those the console already uses: `POST /v1/subscription_schedules`, `POST /v1/subscription_schedules/{id}`, `POST /v1/subscription_schedules/{id}/release` and `POST /v1/invoices/{id}/void`. Stripe names the missing permission in the error a key without it receives.
+4
View File
@@ -271,6 +271,10 @@ type Querier interface {
// mirrorItems writes, in one statement for the organization page's Billing
// card (displaced-subscription-waits D4).
ListCurrentSubscriptionItemsByBillingAccountID(ctx context.Context, billingAccountID string) ([]ListCurrentSubscriptionItemsByBillingAccountIDRow, error)
// The current items of one page of the operator's subscriptions list, the
// rows mirrorItems writes, in one statement for the page
// (displaced-subscription-waits D4).
ListCurrentSubscriptionItemsBySubscriptionIDs(ctx context.Context, subscriptionIds []string) ([]ListCurrentSubscriptionItemsBySubscriptionIDsRow, error)
// Sweeper backstop: due, not-yet-fired rows. Concurrency safety comes from the
// per-row claim (MarkScheduledChangeApplied), not row locking, so a plain read
// is sufficient.
@@ -40,3 +40,13 @@ JOIN core.subscriptions s USING (subscription_id)
WHERE s.billing_account_id = sqlc.arg(billing_account_id)::uuid
AND si.ended_at IS NULL
ORDER BY si.subscription_id, si.product_id;
-- name: ListCurrentSubscriptionItemsBySubscriptionIDs :many
-- The current items of one page of the operator's subscriptions list, the
-- rows mirrorItems writes, in one statement for the page
-- (displaced-subscription-waits D4).
SELECT si.subscription_id, si.product_id
FROM core.subscription_items si
WHERE si.subscription_id = ANY(sqlc.arg(subscription_ids)::uuid[])
AND si.ended_at IS NULL
ORDER BY si.subscription_id, si.product_id;
@@ -10,6 +10,8 @@ package billing
import (
"context"
"github.com/lib/pq"
)
const createSubscriptionItem = `-- name: CreateSubscriptionItem :one
@@ -164,6 +166,45 @@ func (q *Queries) ListCurrentSubscriptionItemsByBillingAccountID(ctx context.Con
return items, nil
}
const listCurrentSubscriptionItemsBySubscriptionIDs = `-- name: ListCurrentSubscriptionItemsBySubscriptionIDs :many
SELECT si.subscription_id, si.product_id
FROM core.subscription_items si
WHERE si.subscription_id = ANY($1::uuid[])
AND si.ended_at IS NULL
ORDER BY si.subscription_id, si.product_id
`
type ListCurrentSubscriptionItemsBySubscriptionIDsRow struct {
SubscriptionID string `json:"subscription_id"`
ProductID string `json:"product_id"`
}
// The current items of one page of the operator's subscriptions list, the
// rows mirrorItems writes, in one statement for the page
// (displaced-subscription-waits D4).
func (q *Queries) ListCurrentSubscriptionItemsBySubscriptionIDs(ctx context.Context, subscriptionIds []string) ([]ListCurrentSubscriptionItemsBySubscriptionIDsRow, error) {
rows, err := q.db.QueryContext(ctx, listCurrentSubscriptionItemsBySubscriptionIDs, pq.Array(subscriptionIds))
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListCurrentSubscriptionItemsBySubscriptionIDsRow{}
for rows.Next() {
var i ListCurrentSubscriptionItemsBySubscriptionIDsRow
if err := rows.Scan(&i.SubscriptionID, &i.ProductID); 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
}
const updateSubscriptionItemPriceProduct = `-- name: UpdateSubscriptionItemPriceProduct :one
UPDATE core.subscription_items
SET product_id = $2, price_id = $3, quantity = $4
@@ -58,10 +58,11 @@
recent invoice. Each row is a muted label over a value line; a
badge leads only where there is a state to read. The account's
own status is a fourth row that appears only when it is not
active, since a permanent Active badge would say nothing. Under the
Subscriptions value line one muted line names the ladders a
subscription delivers nothing on, or says the read for it failed
(DisplacementNote, DisplacementError). */}}
active, since a permanent Active badge would say nothing. The
Subscriptions value line carries the Not delivering badge after
its state badge when a subscription delivers nothing
(DisplacementTitle); one danger line under it says the read for it
failed (DisplacementError). */}}
<div class="row g-3">
{{ if .BillingSummary.AccountAbnormal }}
<div class="col-12">
@@ -85,6 +86,9 @@
{{ if gt .BillingSummary.SubscriptionCount 0 }}
<div>
{{ template "statusBadge" .BillingSummary.SubscriptionsBadge }}
{{- if .BillingSummary.DisplacementTitle }}
{{ template "statusBadge" .BillingSummary.DisplacementBadge }}
{{- end }}
<a href="{{ .SubscriptionsURL }}">{{ .BillingSummary.SubscriptionCountLabel }}</a>
{{ if .BillingSummary.SubscriptionNote }}
<small class="text-muted">· {{ .BillingSummary.SubscriptionNote }}</small>
@@ -92,8 +96,6 @@
</div>
{{- if .BillingSummary.DisplacementError }}
<div class="small text-danger" role="alert">{{ .BillingSummary.DisplacementError }}</div>
{{- else if .BillingSummary.DisplacementNote }}
<div class="text-muted small">{{ .BillingSummary.DisplacementNote }}</div>
{{- end }}
{{ else }}
<div class="text-muted small">No subscriptions</div>
@@ -7,7 +7,10 @@
through statusBadge, the empty and blocked states through emptyState.
The organization is the row's subject and leads as the row header
(chrome-conventions); cancel-at-period-end is an exception marker
beside the status, the always-"Default" billing account name and the
beside the status, and so is the Not delivering badge of a subscription
that bills while another plan holds its ladder (DisplacementTitle); one
danger line above the table says the read for it failed
(DisplacementError); the always-"Default" billing account name and the
truncated ID column are retired (maintainer, 2026-08-30); a
subscription has no detail page today, so no cell links its own. */}}
{{ if .Error }}
@@ -25,6 +28,7 @@
{{ end }}
{{ else }}
{{ template "listControls" .Nav }}
{{ if .DisplacementError }}<div class="small text-danger mb-2" role="alert">{{ .DisplacementError }}</div>{{ end }}
<div class="table-responsive">
<table class="table table-hover table-sm table-record">
<thead>
@@ -49,6 +53,7 @@
<td>
{{ template "statusBadge" .StatusBadge }}
{{ if .CancelAtPeriodEnd }}{{ template "statusBadge" .CancelingBadge }}{{ end }}
{{ if .DisplacementTitle }}{{ template "statusBadge" .DisplacementBadge }}{{ end }}
</td>
<td class="text-nowrap">
{{ if .CurrentPeriodStart }}
@@ -14,6 +14,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/lib/pq"
)
const listAccountSubscriptionDisplacements = `-- name: ListAccountSubscriptionDisplacements :many
@@ -229,6 +230,72 @@ func (q *Queries) ListSubscriptionDisplacements(ctx context.Context, arg ListSub
return items, nil
}
const listSubscriptionDisplacementsBySubscriptionIDs = `-- name: ListSubscriptionDisplacementsBySubscriptionIDs :many
SELECT s.subscription_id::uuid AS subscription_id,
d.plan_ladder_id::uuid AS plan_ladder_id,
pl.name AS ladder_name,
d.displaced_product_id::uuid AS displaced_product_id,
d.displaced_at::timestamptz AS displaced_at,
d.holder_provision_id::uuid AS holder_provision_id,
d.holder_source::text AS holder_source
FROM core.subscriptions s
JOIN core.accounts a ON a.billing_account_id = s.billing_account_id
JOIN core.resource_pools rp
ON rp.org_id = a.org_id AND rp.pool_type = 'default' AND rp.status = 'active'
CROSS JOIN LATERAL core.subscription_displacements(s.subscription_id, rp.pool_id)
AS d(plan_ladder_id, displaced_product_id, displaced_at, holder_provision_id, holder_source)
JOIN core.plan_ladders pl ON pl.plan_ladder_id = d.plan_ladder_id::uuid
WHERE s.subscription_id = ANY($1::uuid[])
ORDER BY s.subscription_id, pl.name, d.plan_ladder_id::uuid, d.holder_provision_id::uuid
`
type ListSubscriptionDisplacementsBySubscriptionIDsRow struct {
SubscriptionID string `json:"subscription_id"`
PlanLadderID string `json:"plan_ladder_id"`
LadderName string `json:"ladder_name"`
DisplacedProductID string `json:"displaced_product_id"`
DisplacedAt time.Time `json:"displaced_at"`
HolderProvisionID string `json:"holder_provision_id"`
HolderSource string `json:"holder_source"`
}
// core.subscription_displacements applied to each listed subscription with
// its organization's active default pool, the predicate GetDefaultPoolByOrgID
// states and the reconcile resolves its pool by, in one statement for the
// operator's subscriptions list (displaced-subscription-waits D4), never one
// per row. The aliases repeat the function's columns because sqlc cannot read
// a function's RETURNS TABLE. The status filter stays in Go.
func (q *Queries) ListSubscriptionDisplacementsBySubscriptionIDs(ctx context.Context, subscriptionIds []string) ([]ListSubscriptionDisplacementsBySubscriptionIDsRow, error) {
rows, err := q.db.QueryContext(ctx, listSubscriptionDisplacementsBySubscriptionIDs, pq.Array(subscriptionIds))
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListSubscriptionDisplacementsBySubscriptionIDsRow{}
for rows.Next() {
var i ListSubscriptionDisplacementsBySubscriptionIDsRow
if err := rows.Scan(
&i.SubscriptionID,
&i.PlanLadderID,
&i.LadderName,
&i.DisplacedProductID,
&i.DisplacedAt,
&i.HolderProvisionID,
&i.HolderSource,
); 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
}
const listTransitionsByPool = `-- name: ListTransitionsByPool :many
SELECT
+7
View File
@@ -572,6 +572,13 @@ type Querier interface {
// statement; the aliases repeat its columns because sqlc cannot read a
// function's RETURNS TABLE. The reconcile calls it under the pool row.
ListSubscriptionDisplacements(ctx context.Context, arg ListSubscriptionDisplacementsParams) ([]ListSubscriptionDisplacementsRow, error)
// core.subscription_displacements applied to each listed subscription with
// its organization's active default pool, the predicate GetDefaultPoolByOrgID
// states and the reconcile resolves its pool by, in one statement for the
// operator's subscriptions list (displaced-subscription-waits D4), never one
// per row. The aliases repeat the function's columns because sqlc cannot read
// a function's RETURNS TABLE. The status filter stays in Go.
ListSubscriptionDisplacementsBySubscriptionIDs(ctx context.Context, subscriptionIds []string) ([]ListSubscriptionDisplacementsBySubscriptionIDsRow, error)
// The pools among pool_ids (the pools the tier add has locked) that the add
// cannot align the product's provisions in, ascending by pool_id: a pool that
// already holds a live position on the ladder (held_product_name is the
@@ -103,3 +103,27 @@ CROSS JOIN LATERAL core.subscription_displacements(s.subscription_id, sqlc.arg(p
JOIN core.plan_ladders pl ON pl.plan_ladder_id = d.plan_ladder_id::uuid
WHERE s.billing_account_id = sqlc.arg(billing_account_id)::uuid
ORDER BY s.subscription_id, pl.name, d.plan_ladder_id::uuid, d.holder_provision_id::uuid;
-- name: ListSubscriptionDisplacementsBySubscriptionIDs :many
-- core.subscription_displacements applied to each listed subscription with
-- its organization's active default pool, the predicate GetDefaultPoolByOrgID
-- states and the reconcile resolves its pool by, in one statement for the
-- operator's subscriptions list (displaced-subscription-waits D4), never one
-- per row. The aliases repeat the function's columns because sqlc cannot read
-- a function's RETURNS TABLE. The status filter stays in Go.
SELECT s.subscription_id::uuid AS subscription_id,
d.plan_ladder_id::uuid AS plan_ladder_id,
pl.name AS ladder_name,
d.displaced_product_id::uuid AS displaced_product_id,
d.displaced_at::timestamptz AS displaced_at,
d.holder_provision_id::uuid AS holder_provision_id,
d.holder_source::text AS holder_source
FROM core.subscriptions s
JOIN core.accounts a ON a.billing_account_id = s.billing_account_id
JOIN core.resource_pools rp
ON rp.org_id = a.org_id AND rp.pool_type = 'default' AND rp.status = 'active'
CROSS JOIN LATERAL core.subscription_displacements(s.subscription_id, rp.pool_id)
AS d(plan_ladder_id, displaced_product_id, displaced_at, holder_provision_id, holder_source)
JOIN core.plan_ladders pl ON pl.plan_ladder_id = d.plan_ladder_id::uuid
WHERE s.subscription_id = ANY(sqlc.arg(subscription_ids)::uuid[])
ORDER BY s.subscription_id, pl.name, d.plan_ladder_id::uuid, d.holder_provision_id::uuid;
+5 -4
View File
@@ -676,7 +676,8 @@ type Rung struct {
// RungsByProduct groups the conferral shapes' rows by product, each product's
// rungs in the rows' order (ladder id order, as ListConferralShapes returns
// them). It reads no row, and it is exported because the organization page
// applies the same rule to a subscription's current products.
// and the operator's subscriptions list apply the same rule to a
// subscription's current products.
func RungsByProduct(rows []entitlements.ListConferralShapesRow) map[string][]Rung {
rungs := make(map[string][]Rung)
for _, r := range rows {
@@ -733,9 +734,9 @@ func displacementsFromRows(rows []entitlements.ListSubscriptionDisplacementsRow)
// whose ladder the subscription is displaced on. The check is per ladder, so
// another product of a displaced ladder is left alone too. It indexes the
// displacements by ladder, keeping the first row of each, and reads no row:
// the reconcile enforces the rule and the organization page reads it too, so
// the card never says a subscription delivers nothing on a ladder the
// reconcile would confer.
// the reconcile enforces the rule and the organization page and the
// subscriptions list read it too, so neither the card nor the list marks a
// subscription Not delivering on a ladder the reconcile would confer.
func DisplacedProducts(missing []string, rungs map[string][]Rung, displacements []Displacement) map[string]Displacement {
byLadder := make(map[string]Displacement, len(displacements))
for _, d := range displacements {
+24 -1
View File
@@ -190,6 +190,9 @@ var badgeMap = map[string]Badge{
"trialing": {Label: "Trialing", Tone: "info"},
"past_due": {Label: "Past due", Tone: "danger"},
"paused": {Label: "Paused", Tone: "secondary"},
// A subscription that keeps billing while another plan holds one of its
// ladders (displaced-subscription-waits D4); its title says which.
"not_delivering": {Label: "Not delivering", Tone: "warning"},
// The subscription's own unpaid state, as loud as its past-due state.
"subscription_unpaid": {Label: "Unpaid", Tone: "danger"},
// A suspended plan the member can still pay for (the entitlements panel)
@@ -356,7 +359,7 @@ var knownStates = []string{
"purchasable", "ready_to_grant",
"paid", "unpaid", "overdue", "verified", "unverified", "draft", "published", "retired",
"expired", "canceled", "released", "revoked",
"trialing", "past_due", "paused", "payment_due", "subscription_unpaid", "suspended", "incomplete_expired", "initiate", "upgrade", "downgrade", "transfer", "extension", "resumption", "end", "entitlement_set",
"trialing", "past_due", "paused", "not_delivering", "payment_due", "subscription_unpaid", "suspended", "incomplete_expired", "initiate", "upgrade", "downgrade", "transfer", "extension", "resumption", "end", "entitlement_set",
"rule_added", "rule_modified", "rule_deactivated",
"open", "void", "uncollectible", "refunded", "partially_paid", "succeeded", "failed", "closed",
"canceling", "shared", "current", "hosted", "awaiting_dns",
@@ -725,6 +728,19 @@ func (b BillingSummaryViewModel) SubscriptionsBadge() Badge {
return badge
}
// DisplacementBadge is the Not delivering badge after the Subscriptions row's
// state badge, its title naming the ladders and what holds them (Copy rows O1
// and O2). The template renders it only when DisplacementTitle is set.
func (b BillingSummaryViewModel) DisplacementBadge() Badge {
return notDeliveringBadge(b.DisplacementTitle)
}
// notDeliveringBadge is the one place the Not delivering badge is built, for
// the Billing card and the subscriptions list.
func notDeliveringBadge(title string) Badge {
return StatusBadge("not_delivering").WithTitle(title)
}
// subscriptionStatusBadge is the badge of a subscription's state. Its unpaid
// state has a key of its own, in the danger tone, because the subscription is
// suspended; the invoice's and the balance's unpaid keep the warning tone.
@@ -1066,6 +1082,13 @@ func (SubscriptionViewModel) CancelingBadge() Badge {
return StatusBadge("canceling").WithTitle("Cancels at the period end and will not renew.")
}
// DisplacementBadge is the Not delivering badge in the list's Status cell
// (Copy rows O1 and O2); the template renders it only when DisplacementTitle
// is set.
func (s SubscriptionViewModel) DisplacementBadge() Badge {
return notDeliveringBadge(s.DisplacementTitle)
}
// Group 7 of the sweep (Entitlement sets list, detail, edit, rules): the
// pages' values for the parts.
+72 -4
View File
@@ -16,7 +16,9 @@ import (
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
"git.coopcloud.tech/wiki-cafe/member-console/internal/fulfillment"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/catalogsync"
stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
@@ -443,12 +445,20 @@ type SubscriptionViewModel struct {
// EnvState marks a row from the environment the key is not in, in the
// all state only; see BillingAccountViewModel.
EnvState string
// DisplacementTitle is the title of the Not delivering badge, empty when
// the subscription delivers (operator-billing-views, "The subscriptions
// list marks a subscription that delivers nothing while another plan
// holds its ladder").
DisplacementTitle string
}
// SubscriptionsData holds data for the subscriptions list partial
type SubscriptionsData struct {
Subscriptions []SubscriptionViewModel
Error string
// DisplacementError is the line above the table when a read for the
// badges failed; no row then carries the badge (Copy row O4).
DisplacementError string
// EnvNotice is the absence line and its switch; see BillingAccountsData.
EnvNotice EnvironmentNotice
// StripeConfigured gates the empty-view copy; see BillingAccountsData.
@@ -534,6 +544,14 @@ func (h *OperatorPartialsHandler) loadSubscriptionsData(r *http.Request) Subscri
mappings = mappingsByID(rows, err, func(m stripedb.SubscriptionMapping) string { return m.SubscriptionID })
}
titles, titlesErr := h.subscriptionDisplacementTitles(ctx, subs)
displacementError := ""
if titlesErr != nil {
h.Logger.Warn("billing subscriptions: subscription displacement read failed", slog.Any("error", titlesErr))
displacementError = "Failed to load deliveries."
titles = nil
}
names := h.names(ctx)
names.WantOrgs(idsOf(subs, func(row billing.ListSubscriptionsPageRow) string { return row.OrgID })...)
@@ -579,16 +597,66 @@ func (h *OperatorPartialsHandler) loadSubscriptionsData(r *http.Request) Subscri
StripeSyncStatus: syncStatus,
CreatedAt: sub.CreatedAt.Format("Jan 2, 2006"),
EnvState: envState,
DisplacementTitle: titles[sub.SubscriptionID],
}
}
return SubscriptionsData{
Subscriptions: vms,
StripeConfigured: stripeConfigured,
Nav: nav,
EnvNotice: environmentNotice(nav, env, "subscription", "subscriptions"),
Subscriptions: vms,
DisplacementError: displacementError,
StripeConfigured: stripeConfigured,
Nav: nav,
EnvNotice: environmentNotice(nav, env, "subscription", "subscriptions"),
}
}
// subscriptionDisplacementTitles reads the page's current items, their
// conferral shapes and the page's displacements, once each, and returns the
// Not delivering badge title of each displaced subscription by id. With no
// subscription it reads nothing (displaced-subscription-waits D4).
func (h *OperatorPartialsHandler) subscriptionDisplacementTitles(ctx context.Context, subs []billing.ListSubscriptionsPageRow) (map[string]string, error) {
if len(subs) == 0 {
return nil, nil
}
ids := idsOf(subs, func(row billing.ListSubscriptionsPageRow) string { return row.SubscriptionID })
items, err := h.BillingQ.ListCurrentSubscriptionItemsBySubscriptionIDs(ctx, ids)
if err != nil {
return nil, fmt.Errorf("list current subscription items: %w", err)
}
products := make([]string, len(items))
for i, it := range items {
products[i] = it.ProductID
}
shapes, err := h.EntitlementsQ.ListConferralShapes(ctx, products)
if err != nil {
return nil, fmt.Errorf("list conferral shapes: %w", err)
}
rows, err := h.EntitlementsQ.ListSubscriptionDisplacementsBySubscriptionIDs(ctx, ids)
if err != nil {
return nil, fmt.Errorf("list subscription displacements: %w", err)
}
return subscriptionTitlesFor(subs, items, shapes, rows), nil
}
// subscriptionTitlesFor words the badge title of each displaced subscription
// of the page, by subscription id. A row is one subscription, so each title
// is worded alone, never in the account's form. It reads no row.
func subscriptionTitlesFor(subs []billing.ListSubscriptionsPageRow, items []billing.ListCurrentSubscriptionItemsBySubscriptionIDsRow, shapes []entitlements.ListConferralShapesRow, rows []entitlements.ListSubscriptionDisplacementsBySubscriptionIDsRow) map[string]string {
refs := make([]subscriptionRef, len(subs))
for i, s := range subs {
refs[i] = subscriptionRef{ID: s.SubscriptionID, Status: s.Status}
}
converted := make([]entitlements.ListAccountSubscriptionDisplacementsRow, len(rows))
for i, r := range rows {
converted[i] = entitlements.ListAccountSubscriptionDisplacementsRow(r)
}
byID := displacedByID(refs, productsBySubscription(items), fulfillment.RungsByProduct(shapes), displacementsBySubscription(converted))
titles := make(map[string]string, len(byID))
for id, d := range byID {
titles[id] = displacementTitle(1, []displacedSubscription{d})
}
return titles
}
// InvoiceViewModel represents an invoice for operator template rendering
type InvoiceViewModel struct {
InvoiceID string
+101 -57
View File
@@ -341,12 +341,13 @@ type BillingSummaryViewModel struct {
SubscriptionCount int
SubscriptionState string
SubscriptionNote string
// DisplacementNote and DisplacementError render the one muted line under
// the Subscriptions row (plan-enrollment-administration, "The billing card
// says when a subscription delivers nothing while another plan holds its
// ladder"). DisplacementError replaces the note when a read for it failed,
// and the note is then empty.
DisplacementNote string
// DisplacementTitle is the title of the Not delivering badge after the
// Subscriptions row's state badge, empty when no subscription is displaced
// (plan-enrollment-administration, "The billing card says when a
// subscription delivers nothing while another plan holds its ladder").
// DisplacementError replaces the badge with the line under the value line
// when a read for it failed, and the title is then empty.
DisplacementTitle string
DisplacementError string
HasInvoice bool
LatestInvoiceID string
@@ -454,12 +455,14 @@ func subscriptionPeriodNote(state string, cancelAtPeriodEnd bool, periodEnd sql.
}
// subscriptionMayBeDisplaced reports whether a subscription in the status
// still pays for its tiers, so another plan holding one of its ladders is a
// cost the card names: the statuses whose reconcile runs the item pass or the
// suspension. A canceled or incomplete subscription pays for nothing.
// still bills for its tiers, so another plan holding one of its ladders is a
// cost the badge names: active, trialing, past_due and unpaid. A paused
// subscription generates no invoice, so the badge's title would be false; the
// badge appears once it is active again (displaced-subscription-waits D4). A
// canceled or incomplete subscription pays for nothing.
func subscriptionMayBeDisplaced(status string) bool {
switch status {
case "active", "trialing", "past_due", "unpaid", "paused":
case "active", "trialing", "past_due", "unpaid":
return true
}
return false
@@ -503,12 +506,14 @@ func displacedOnPage(products []string, rungs map[string][]fulfillment.Rung, dis
return out
}
// displacementNote builds the line under the Subscriptions row (Copy rows O1
// and O2): "" when nothing is displaced, O1 for an account of one
// subscription, O2 otherwise. The ladders are the distinct displaced ladders
// by id, sorted by name, whatever order they arrive in; the holder is worded
// by source when one provision holds every ladder. It reads no row.
func displacementNote(subscriptions int, displaced []displacedSubscription) string {
// displacementTitle builds the Not delivering badge's title (Copy row O2): ""
// when nothing is displaced, "Billing continues while <holder> <ladders>." for
// an account of one subscription, and "Billing continues on <k> of <n>
// subscriptions while <holder> <ladders>." otherwise. The ladders are the
// distinct displaced ladders by id, sorted by name, whatever order they
// arrive in; the holder is worded by source when one provision holds every
// ladder. It reads no row.
func displacementTitle(subscriptions int, displaced []displacedSubscription) string {
var ladders []displacedLadder
var holders []displacedHolder
seenLadder := make(map[string]bool)
@@ -545,15 +550,10 @@ func displacementNote(subscriptions int, displaced []displacedSubscription) stri
for i, l := range ladders {
names[i] = l.Name
}
tail := "which " + displacementHolder(holders)
if subscriptions == 1 {
return "Delivers nothing on " + joinWithAnd(names) + ", " + tail
return "Billing continues while " + displacementHolder(holders) + " " + joinWithAnd(names) + "."
}
verb := "deliver"
if count == 1 {
verb = "delivers"
}
return fmt.Sprintf("%d %s nothing on %s, %s", count, verb, joinWithAnd(names), tail)
return fmt.Sprintf("Billing continues on %d of %d subscriptions while %s %s.", count, subscriptions, displacementHolder(holders), joinWithAnd(names))
}
// displacementHolder words the holders of the displaced ladders: by source
@@ -1692,8 +1692,9 @@ func transitionRow(trn entitlements.ListTransitionsByPoolRow, names *operatorNam
// state, which a single latest row cannot produce (design D5, round 5).
//
// The pools are the active ones loadOrgPools read; the organization's default
// pool is the one the displacement line reads (displaced-subscription-waits
// D4). With none among them the line is not read.
// pool is the one the displacement badge's title reads
// (displaced-subscription-waits D4). With none among them the title is not
// read.
func (h *OperatorPartialsHandler) loadOrgBillingSummary(r *http.Request, data *OrgEnrollmentData, orgID string, pools []entitlements.ResourcePool) {
accounts, err := h.BillingQ.ListBillingAccountsByOrgID(r.Context(), orgID)
if err == nil && len(accounts) > 0 {
@@ -1737,18 +1738,18 @@ func (h *OperatorPartialsHandler) loadOrgBillingSummary(r *http.Request, data *O
}
}
// fillDisplacement sets the card's displacement line from three statements for
// the account, none per subscription: the current items, the shapes of their
// products and the displacements. A failed read logs and sets the error in
// the line's place, never an empty answer ("A failed read is never an
// answer", docs/go-conventions.md). Without a subscription or an active
// default pool nothing is read.
// fillDisplacement sets the card's displacement badge title from three
// statements for the account, none per subscription: the current items, the
// shapes of their products and the displacements. A failed read logs and sets
// the error in the badge's place, never an empty answer ("A failed read is
// never an answer", docs/go-conventions.md). Without a subscription or an
// active default pool nothing is read.
func (h *OperatorPartialsHandler) fillDisplacement(r *http.Request, summary *BillingSummaryViewModel, orgID string, pools []entitlements.ResourcePool, subs []billing.Subscription) {
poolID := defaultPoolID(pools)
if poolID == "" || len(subs) == 0 {
return
}
note, err := h.displacementLine(r, summary.BillingAccountID, poolID, subs)
title, err := h.accountDisplacementTitle(r, summary.BillingAccountID, poolID, subs)
if err != nil {
h.Logger.Warn("org enrollment: subscription displacement read failed",
slog.String("org_id", orgID),
@@ -1757,7 +1758,7 @@ func (h *OperatorPartialsHandler) fillDisplacement(r *http.Request, summary *Bil
summary.DisplacementError = "Failed to load deliveries."
return
}
summary.DisplacementNote = note
summary.DisplacementTitle = title
}
// defaultPoolID returns the id of the organization's default pool among its
@@ -1771,10 +1772,10 @@ func defaultPoolID(pools []entitlements.ResourcePool) string {
return ""
}
// displacementLine reads the account's current items, their conferral shapes
// and its subscriptions' displacements, and words the line for the
// subscriptions whose status can be displaced.
func (h *OperatorPartialsHandler) displacementLine(r *http.Request, accountID, poolID string, subs []billing.Subscription) (string, error) {
// accountDisplacementTitle reads the account's current items, their
// conferral shapes and its subscriptions' displacements, and words the badge
// title for the subscriptions whose status can be displaced.
func (h *OperatorPartialsHandler) accountDisplacementTitle(r *http.Request, accountID, poolID string, subs []billing.Subscription) (string, error) {
items, err := h.BillingQ.ListCurrentSubscriptionItemsByBillingAccountID(r.Context(), accountID)
if err != nil {
return "", fmt.Errorf("list current subscription items: %w", err)
@@ -1794,22 +1795,40 @@ func (h *OperatorPartialsHandler) displacementLine(r *http.Request, accountID, p
if err != nil {
return "", fmt.Errorf("list account subscription displacements: %w", err)
}
return displacementLineFor(subs, items, shapes, rows), nil
return displacementTitleFor(subs, items, shapes, rows), nil
}
// displacementLineFor words the line from the account's subscriptions, their
// current items, those products' conferral shapes and the account's
// displacements, for the subscriptions whose status can be displaced. It
// reads no row.
func displacementLineFor(subs []billing.Subscription, items []billing.ListCurrentSubscriptionItemsByBillingAccountIDRow, shapes []entitlements.ListConferralShapesRow, rows []entitlements.ListAccountSubscriptionDisplacementsRow) string {
productsBySub := make(map[string][]string)
for _, it := range items {
productsBySub[it.SubscriptionID] = append(productsBySub[it.SubscriptionID], it.ProductID)
// subscriptionRef is the part of a subscription displacedByID reads.
type subscriptionRef struct {
ID string
Status string
}
// displacedByID applies the reconcile's rule to each subscription whose
// status subscriptionMayBeDisplaced admits, over its current products, the
// rungs and its displacements. A subscription with no displaced ladder has no
// entry. The Billing card and the subscriptions list group by subscription
// here. It reads no row.
func displacedByID(subs []subscriptionRef, products map[string][]string, rungs map[string][]fulfillment.Rung, displacements map[string][]fulfillment.Displacement) map[string]displacedSubscription {
out := make(map[string]displacedSubscription)
for _, s := range subs {
if !subscriptionMayBeDisplaced(s.Status) {
continue
}
if d := displacedOnPage(products[s.ID], rungs, displacements[s.ID]); len(d.Ladders) > 0 {
out[s.ID] = d
}
}
rungs := fulfillment.RungsByProduct(shapes)
bySub := make(map[string][]fulfillment.Displacement)
return out
}
// displacementsBySubscription is the one mapping of a displacement row's
// fields to fulfillment.Displacement, grouped by subscription. It reads no
// row.
func displacementsBySubscription(rows []entitlements.ListAccountSubscriptionDisplacementsRow) map[string][]fulfillment.Displacement {
by := make(map[string][]fulfillment.Displacement)
for _, d := range rows {
bySub[d.SubscriptionID] = append(bySub[d.SubscriptionID], fulfillment.Displacement{
by[d.SubscriptionID] = append(by[d.SubscriptionID], fulfillment.Displacement{
LadderID: d.PlanLadderID,
LadderName: d.LadderName,
DisplacedProductID: d.DisplacedProductID,
@@ -1817,16 +1836,41 @@ func displacementLineFor(subs []billing.Subscription, items []billing.ListCurren
HolderSource: d.HolderSource,
})
}
return by
}
// itemRow is the shape both current-item row types share.
type itemRow interface {
billing.ListCurrentSubscriptionItemsByBillingAccountIDRow | billing.ListCurrentSubscriptionItemsBySubscriptionIDsRow
}
// productsBySubscription groups the current items' products by subscription.
func productsBySubscription[R itemRow](items []R) map[string][]string {
by := make(map[string][]string)
for _, it := range items {
row := billing.ListCurrentSubscriptionItemsByBillingAccountIDRow(it)
by[row.SubscriptionID] = append(by[row.SubscriptionID], row.ProductID)
}
return by
}
// displacementTitleFor words the badge title from the account's
// subscriptions, their current items, those products' conferral shapes and
// the account's displacements, for the subscriptions whose status can be
// displaced. It reads no row.
func displacementTitleFor(subs []billing.Subscription, items []billing.ListCurrentSubscriptionItemsByBillingAccountIDRow, shapes []entitlements.ListConferralShapesRow, rows []entitlements.ListAccountSubscriptionDisplacementsRow) string {
refs := make([]subscriptionRef, len(subs))
for i, s := range subs {
refs[i] = subscriptionRef{ID: s.SubscriptionID, Status: s.Status}
}
byID := displacedByID(refs, productsBySubscription(items), fulfillment.RungsByProduct(shapes), displacementsBySubscription(rows))
var displaced []displacedSubscription
for _, s := range subs {
if !subscriptionMayBeDisplaced(s.Status) {
continue
}
if d := displacedOnPage(productsBySub[s.SubscriptionID], rungs, bySub[s.SubscriptionID]); len(d.Ladders) > 0 {
for _, ref := range refs {
if d, ok := byID[ref.ID]; ok {
displaced = append(displaced, d)
}
}
return displacementNote(len(subs), displaced)
return displacementTitle(len(subs), displaced)
}
// loadOrgMembers fills the members section. Each row links to
@@ -2037,8 +2081,8 @@ func (h *OperatorPartialsHandler) productsOnLadders(r *http.Request, ladders map
// supersede a subscription-held rung — the subscription keeps billing (§11.2
// residue). Pure read; best-effort: a pool whose positions fail to read is
// skipped, not surfaced. A suspended position is held: core.confer supersedes
// it as it does an active one, and the subscription stays in Stripe until it
// is changed in the billing view.
// it as it does an active one, and the subscription keeps billing in Stripe,
// or bills again once a paused one resumes.
func (h *OperatorPartialsHandler) subscriptionHeldLadders(r *http.Request, pools []entitlements.ResourcePool) map[string]bool {
held := map[string]bool{}
for _, pool := range pools {
+4 -2
View File
@@ -128,8 +128,10 @@ var issueGrantForm = forms.Register(forms.FormSpec{
const issueGrantLead = "A tier product moves the pool to that tier, up or down."
// issueGrantSupersedeWarning follows the lead when a product the select
// offers is marked with the supersession marker.
const issueGrantSupersedeWarning = "Products marked ⚠ would supersede a tier currently held by a subscription; the subscription keeps billing until it is changed in the billing view."
// offers is marked with the supersession marker. The sentence states what the
// grant does to the subscription and names no view, because the operator's
// billing views only read (displaced-subscription-waits D4, Copy row O5).
const issueGrantSupersedeWarning = "Products marked ⚠ would supersede a tier a subscription pays for; the subscription keeps billing and delivers nothing on that ladder until the grant ends."
// manageGrantForm is the declaration for Manage (grant-resumption D15):
// everything about a grant that can change after issue, with the fields the
@@ -92,6 +92,14 @@ func (q failingEnrollmentBillingQ) ListCurrentSubscriptionItemsByBillingAccountI
return q.Querier.ListCurrentSubscriptionItemsByBillingAccountID(ctx, billingAccountID)
}
func (q failingEnrollmentBillingQ) ListCurrentSubscriptionItemsBySubscriptionIDs(ctx context.Context, subscriptionIds []string) ([]billing.ListCurrentSubscriptionItemsBySubscriptionIDsRow, error) {
if q.fail == "ListCurrentSubscriptionItemsBySubscriptionIDs" {
var none []billing.ListCurrentSubscriptionItemsBySubscriptionIDsRow
return none, errReadFailed
}
return q.Querier.ListCurrentSubscriptionItemsBySubscriptionIDs(ctx, subscriptionIds)
}
func (q failingEnrollmentBillingQ) GetLatestInvoiceByBillingAccountID(ctx context.Context, billingAccountID string) (billing.Invoice, error) {
if q.fail == "GetLatestInvoiceByBillingAccountID" {
var none billing.Invoice
@@ -177,6 +185,14 @@ func (q failingEnrollmentEntitlementsQ) ListAccountSubscriptionDisplacements(ctx
return q.Querier.ListAccountSubscriptionDisplacements(ctx, arg)
}
func (q failingEnrollmentEntitlementsQ) ListSubscriptionDisplacementsBySubscriptionIDs(ctx context.Context, subscriptionIds []string) ([]entitlements.ListSubscriptionDisplacementsBySubscriptionIDsRow, error) {
if q.fail == "ListSubscriptionDisplacementsBySubscriptionIDs" {
var none []entitlements.ListSubscriptionDisplacementsBySubscriptionIDsRow
return none, errReadFailed
}
return q.Querier.ListSubscriptionDisplacementsBySubscriptionIDs(ctx, subscriptionIds)
}
func (q failingEnrollmentEntitlementsQ) ListConferralShapes(ctx context.Context, productIds []string) ([]entitlements.ListConferralShapesRow, error) {
if q.fail == "ListConferralShapes" {
var none []entitlements.ListConferralShapesRow
@@ -380,18 +396,18 @@ func enrollmentReadFailures() []enrollmentReadFailure {
{"a failed product ladder read drops the advisory", "billing", "ListTiersWithProductsByLadderIDs", wantNoAdvisory},
{"a failed billing account read shows no account", "billing", "ListBillingAccountsByOrgID", wantListBillingAccountsByOrgID},
{"a failed subscription read counts none", "billing", "GetSubscriptionsByBillingAccountID", wantGetSubscriptionsByBillingAccountID},
{"a failed displacement item read names the line error", "billing", "ListCurrentSubscriptionItemsByBillingAccountID", wantDisplacementError("ListCurrentSubscriptionItemsByBillingAccountID")},
{"a failed displacement shape read names the line error", "entitlements", "ListConferralShapes", wantDisplacementError("ListConferralShapes")},
{"a failed displacement read names the line error", "entitlements", "ListAccountSubscriptionDisplacements", wantDisplacementError("ListAccountSubscriptionDisplacements")},
{"a failed displacement item read names the badge error", "billing", "ListCurrentSubscriptionItemsByBillingAccountID", wantDisplacementError("ListCurrentSubscriptionItemsByBillingAccountID")},
{"a failed displacement shape read names the badge error", "entitlements", "ListConferralShapes", wantDisplacementError("ListConferralShapes")},
{"a failed displacement read names the badge error", "entitlements", "ListAccountSubscriptionDisplacements", wantDisplacementError("ListAccountSubscriptionDisplacements")},
{"a failed invoice read shows no invoice", "billing", "GetLatestInvoiceByBillingAccountID", wantGetLatestInvoiceByBillingAccountID},
{"a failed balance read shows a zero balance", "billing", "GetOutstandingBalanceByBillingAccountID", wantGetOutstandingBalanceByBillingAccountID},
{"a failed member read names the section error", "organization", "ListActiveMembersByOrgID", wantListActiveMembersByOrgID},
}
}
// wantDisplacementError checks a failed read of the displacement line: the
// error stands in the line's place, in the data and on the page, and the note
// is empty.
// wantDisplacementError checks a failed read of the displacement badge: the
// error replaces the badge, in the data and on the page, and the title is
// empty.
func wantDisplacementError(read string) func(t *testing.T, r enrollmentRead) {
return func(t *testing.T, r enrollmentRead) {
t.Helper()
@@ -399,8 +415,8 @@ func wantDisplacementError(read string) func(t *testing.T, r enrollmentRead) {
if b.DisplacementError != "Failed to load deliveries." {
t.Errorf("DisplacementError after a failed %s read = %q, want \"Failed to load deliveries.\"", read, b.DisplacementError)
}
if b.DisplacementNote != "" {
t.Errorf("DisplacementNote after a failed %s read = %q, want none", read, b.DisplacementNote)
if b.DisplacementTitle != "" {
t.Errorf("DisplacementTitle after a failed %s read = %q, want none", read, b.DisplacementTitle)
}
if !strings.Contains(r.page.Text(), "Failed to load deliveries.") {
t.Errorf("the page after a failed %s read does not hold \"Failed to load deliveries.\"", read)
@@ -8,10 +8,12 @@ import (
"database/sql"
"fmt"
"html/template"
"maps"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"slices"
"strings"
"testing"
"time"
@@ -548,7 +550,7 @@ func TestOrgEnrollmentIssueGrantPanel_Stacked(t *testing.T) {
data.IssuanceProducts = append(data.IssuanceProducts, IssuanceProductOption{ProductID: "prod-pro", Name: "Pro", SupersedesSubscription: true})
data.IssueGrantForm = issueGrantFormView(data.OrgID, data.IssuanceProducts, forms.ModeRecord, forms.NewValues(), nil, "")
warned := renderOrgEnrollment(t, data)
if !strings.Contains(warned, `A tier product moves the pool to that tier, up or down.<div class="alert alert-warning py-1 px-2 small mt-2 mb-0" role="alert">Products marked ⚠ would supersede a tier currently held by a subscription; the subscription keeps billing until it is changed in the billing view.</div>`) {
if !strings.Contains(warned, `A tier product moves the pool to that tier, up or down.<div class="alert alert-warning py-1 px-2 small mt-2 mb-0" role="alert">Products marked ⚠ would supersede a tier a subscription pays for; the subscription keeps billing and delivers nothing on that ladder until the grant ends.</div>`) {
t.Errorf("the supersession warning does not follow the lead:\n%s", warned)
}
}
@@ -699,7 +701,7 @@ func TestOrgEnrollmentProductsSection(t *testing.T) {
if !strings.Contains(out, "Standard Plan ⚠") {
t.Error("expected the ⚠ marker on superseding product options")
}
if !strings.Contains(out, "supersede a tier currently held by a subscription") {
if !strings.Contains(out, "supersede a tier a subscription pays for") {
t.Error("expected the supersession advisory when a ⚠ option is present")
}
})
@@ -1291,14 +1293,14 @@ func displacedOn(holder displacedHolder, ladders ...displacedLadder) displacedSu
return d
}
type displacementNoteRow struct {
type displacementTitleRow struct {
name string
subscriptions int
displaced []displacedSubscription
want string
}
func displacementNoteRows() []displacementNoteRow {
func displacementTitleRows() []displacementTitleRow {
hosting := displacedLadder{ID: "l-hosting", Name: "Hosting"}
support := displacedLadder{ID: "l-support", Name: "Support"}
docs := displacedLadder{ID: "l-docs", Name: "Docs"}
@@ -1306,36 +1308,35 @@ func displacementNoteRows() []displacementNoteRow {
other := displacedHolder{ProvisionID: "p-other", Source: "grant"}
sub := displacedHolder{ProvisionID: "p-sub", Source: "subscription"}
purchase := displacedHolder{ProvisionID: "p-purchase", Source: "purchase"}
return []displacementNoteRow{
{"a grant holds the ladder", 1, []displacedSubscription{displacedOn(grant, hosting)}, "Delivers nothing on Hosting, which a grant holds"},
{"another subscription holds the ladder", 1, []displacedSubscription{displacedOn(sub, hosting)}, "Delivers nothing on Hosting, which another subscription holds"},
{"a purchase holds the ladder", 1, []displacedSubscription{displacedOn(purchase, hosting)}, "Delivers nothing on Hosting, which another plan holds"},
{"one grant holds two ladders", 1, []displacedSubscription{displacedOn(grant, hosting, support)}, "Delivers nothing on Hosting and Support, which a grant holds"},
{"three ladders", 1, []displacedSubscription{displacedOn(grant, docs, hosting, support)}, "Delivers nothing on Docs, Hosting and Support, which a grant holds"},
return []displacementTitleRow{
{"a grant holds the ladder", 1, []displacedSubscription{displacedOn(grant, hosting)}, "Billing continues while a grant holds Hosting."},
{"another subscription holds the ladder", 1, []displacedSubscription{displacedOn(sub, hosting)}, "Billing continues while another subscription holds Hosting."},
{"a purchase holds the ladder", 1, []displacedSubscription{displacedOn(purchase, hosting)}, "Billing continues while another plan holds Hosting."},
{"one grant holds two ladders", 1, []displacedSubscription{displacedOn(grant, hosting, support)}, "Billing continues while a grant holds Hosting and Support."},
{"three ladders", 1, []displacedSubscription{displacedOn(grant, docs, hosting, support)}, "Billing continues while a grant holds Docs, Hosting and Support."},
{"two holders", 1, []displacedSubscription{{
Ladders: []displacedLadder{hosting, support},
Holders: []displacedHolder{grant, other},
}}, "Delivers nothing on Hosting and Support, which other plans hold"},
{"one of several subscriptions", 3, []displacedSubscription{displacedOn(sub, hosting)}, "1 delivers nothing on Hosting, which another subscription holds"},
}}, "Billing continues while other plans hold Hosting and Support."},
{"one of several subscriptions", 3, []displacedSubscription{displacedOn(sub, hosting)}, "Billing continues on 1 of 3 subscriptions while another subscription holds Hosting."},
{"two of several subscriptions", 3, []displacedSubscription{
displacedOn(grant, hosting), displacedOn(other, support),
}, "2 deliver nothing on Hosting and Support, which other plans hold"},
{"ladders out of name order", 1, []displacedSubscription{displacedOn(grant, support, hosting)}, "Delivers nothing on Hosting and Support, which a grant holds"},
}, "Billing continues on 2 of 3 subscriptions while other plans hold Hosting and Support."},
{"ladders out of name order", 1, []displacedSubscription{displacedOn(grant, support, hosting)}, "Billing continues while a grant holds Hosting and Support."},
{"two subscriptions displaced on one ladder", 3, []displacedSubscription{
displacedOn(grant, hosting), displacedOn(grant, hosting),
}, "2 deliver nothing on Hosting, which a grant holds"},
}, "Billing continues on 2 of 3 subscriptions while a grant holds Hosting."},
{"nothing displaced", 2, nil, ""},
}
}
// TestDisplacementNote compares every Copy row of the Billing card's
// displacement line (displaced-subscription-waits O1 and O2) with its whole
// sentence.
func TestDisplacementNote(t *testing.T) {
for _, tc := range displacementNoteRows() {
// TestDisplacementTitle compares every form of the Not delivering badge's
// title (displaced-subscription-waits, Copy row O2) with its whole sentence.
func TestDisplacementTitle(t *testing.T) {
for _, tc := range displacementTitleRows() {
t.Run(tc.name, func(t *testing.T) {
if got := displacementNote(tc.subscriptions, tc.displaced); got != tc.want {
t.Errorf("displacementNote(%s) = %q, want %q", tc.name, got, tc.want)
if got := displacementTitle(tc.subscriptions, tc.displaced); got != tc.want {
t.Errorf("displacementTitle(%s) = %q, want %q", tc.name, got, tc.want)
}
})
}
@@ -1377,7 +1378,7 @@ func displacedOnPageRows() []displacedOnPageRow {
// TestDisplacedOnPage pins that the card applies the reconcile's rule over the
// subscription's current products: a displacement on a ladder no current
// product names reads no line.
// product names reads no badge.
func TestDisplacedOnPage(t *testing.T) {
for _, tc := range displacedOnPageRows() {
t.Run(tc.name, func(t *testing.T) {
@@ -1392,7 +1393,7 @@ func TestDisplacedOnPage(t *testing.T) {
// offers.
func TestSubscriptionMayBeDisplaced(t *testing.T) {
want := map[string]bool{
"active": true, "trialing": true, "past_due": true, "unpaid": true, "paused": true,
"active": true, "trialing": true, "past_due": true, "unpaid": true, "paused": false,
"incomplete": false, "incomplete_expired": false, "canceled": false,
}
for _, f := range subscriptionStatusFacets {
@@ -1408,54 +1409,142 @@ func TestSubscriptionMayBeDisplaced(t *testing.T) {
}
}
const displacementLineForLadder = "6f1b6c1e-0000-4000-8000-000000000001"
const (
displacementTitleForLadder = "6f1b6c1e-0000-4000-8000-000000000001"
displacementTitleForLadder2 = "6f1b6c1e-0000-4000-8000-000000000002"
)
type displacementLineForRow struct {
type displacementTitleForRow struct {
name string
subs []billing.Subscription
rows []entitlements.ListAccountSubscriptionDisplacementsRow
want string
}
func displacementLineForRows() []displacementLineForRow {
func displacementTitleForRows() []displacementTitleForRow {
displaced := func(sub string) entitlements.ListAccountSubscriptionDisplacementsRow {
return entitlements.ListAccountSubscriptionDisplacementsRow{
SubscriptionID: sub, PlanLadderID: displacementLineForLadder, LadderName: "Hosting",
SubscriptionID: sub, PlanLadderID: displacementTitleForLadder, LadderName: "Hosting",
DisplacedProductID: "tier", HolderProvisionID: "p-grant", HolderSource: "grant",
}
}
sub := func(id, status string) billing.Subscription {
return billing.Subscription{SubscriptionID: id, Status: status}
}
return []displacementLineForRow{
{"a canceled subscription has no line", []billing.Subscription{sub("s1", "canceled")},
return []displacementTitleForRow{
{"a canceled subscription has no badge", []billing.Subscription{sub("s1", "canceled")},
[]entitlements.ListAccountSubscriptionDisplacementsRow{displaced("s1")}, ""},
{"an active subscription has the line", []billing.Subscription{sub("s1", "active")},
{"an active subscription has the badge", []billing.Subscription{sub("s1", "active")},
[]entitlements.ListAccountSubscriptionDisplacementsRow{displaced("s1")},
"Delivers nothing on Hosting, which a grant holds"},
"Billing continues while a grant holds Hosting."},
{"one active of two", []billing.Subscription{sub("s1", "active"), sub("s2", "canceled")},
[]entitlements.ListAccountSubscriptionDisplacementsRow{displaced("s1"), displaced("s2")},
"1 delivers nothing on Hosting, which a grant holds"},
"Billing continues on 1 of 2 subscriptions while a grant holds Hosting."},
}
}
// TestDisplacementLineFor pins that a canceled subscription, which keeps its
// current items and its displacement row, gets no line.
func TestDisplacementLineFor(t *testing.T) {
// TestDisplacementTitleFor pins that a canceled subscription, which keeps its
// current items and its displacement row, gets no badge.
func TestDisplacementTitleFor(t *testing.T) {
shapes := []entitlements.ListConferralShapesRow{{
ProductID: "tier",
PlanLadderID: uuid.NullUUID{UUID: uuid.MustParse(displacementLineForLadder), Valid: true},
PlanLadderID: uuid.NullUUID{UUID: uuid.MustParse(displacementTitleForLadder), Valid: true},
Rank: sql.NullInt32{Int32: 1, Valid: true},
}}
for _, tc := range displacementLineForRows() {
for _, tc := range displacementTitleForRows() {
t.Run(tc.name, func(t *testing.T) {
var items []billing.ListCurrentSubscriptionItemsByBillingAccountIDRow
for _, s := range tc.subs {
items = append(items, billing.ListCurrentSubscriptionItemsByBillingAccountIDRow{SubscriptionID: s.SubscriptionID, ProductID: "tier"})
}
if got := displacementLineFor(tc.subs, items, shapes, tc.rows); got != tc.want {
t.Errorf("displacementLineFor(%s) = %q, want %q", tc.name, got, tc.want)
if got := displacementTitleFor(tc.subs, items, shapes, tc.rows); got != tc.want {
t.Errorf("displacementTitleFor(%s) = %q, want %q", tc.name, got, tc.want)
}
})
}
}
type subscriptionTitlesRow struct {
name string
subs []billing.ListSubscriptionsPageRow
rows []entitlements.ListSubscriptionDisplacementsBySubscriptionIDsRow
want map[string]string
}
func subscriptionTitlesRows() []subscriptionTitlesRow {
displaced := func(sub, ladder, name, source string) entitlements.ListSubscriptionDisplacementsBySubscriptionIDsRow {
return entitlements.ListSubscriptionDisplacementsBySubscriptionIDsRow{
SubscriptionID: sub, PlanLadderID: ladder, LadderName: name,
DisplacedProductID: "tier-" + name, HolderProvisionID: "p-" + source, HolderSource: source,
}
}
sub := func(id, status string) billing.ListSubscriptionsPageRow {
return billing.ListSubscriptionsPageRow{SubscriptionID: id, Status: status}
}
hosting, docs := displacementTitleForLadder, displacementTitleForLadder2
return []subscriptionTitlesRow{
{"a canceled subscription has no badge", []billing.ListSubscriptionsPageRow{sub("s1", "canceled")},
[]entitlements.ListSubscriptionDisplacementsBySubscriptionIDsRow{displaced("s1", hosting, "Hosting", "grant")}, map[string]string{}},
{"each displaced row has its own title", []billing.ListSubscriptionsPageRow{sub("s1", "active"), sub("s2", "active")},
[]entitlements.ListSubscriptionDisplacementsBySubscriptionIDsRow{
displaced("s1", hosting, "Hosting", "grant"), displaced("s2", docs, "Docs", "subscription"),
}, map[string]string{
"s1": "Billing continues while a grant holds Hosting.",
"s2": "Billing continues while another subscription holds Docs.",
}},
{"a delivering row has no badge", []billing.ListSubscriptionsPageRow{sub("s1", "active"), sub("s2", "active")},
[]entitlements.ListSubscriptionDisplacementsBySubscriptionIDsRow{displaced("s1", hosting, "Hosting", "grant")},
map[string]string{"s1": "Billing continues while a grant holds Hosting."}},
}
}
// TestSubscriptionTitlesFor pins that the subscriptions list words each
// displaced row alone, never in the account's form, and gives a canceled or a
// delivering row no entry.
func TestSubscriptionTitlesFor(t *testing.T) {
shapes := []entitlements.ListConferralShapesRow{
{
ProductID: "tier-Hosting",
PlanLadderID: uuid.NullUUID{UUID: uuid.MustParse(displacementTitleForLadder), Valid: true},
Rank: sql.NullInt32{Int32: 1, Valid: true},
},
{
ProductID: "tier-Docs",
PlanLadderID: uuid.NullUUID{UUID: uuid.MustParse(displacementTitleForLadder2), Valid: true},
Rank: sql.NullInt32{Int32: 1, Valid: true},
},
}
for _, tc := range subscriptionTitlesRows() {
t.Run(tc.name, func(t *testing.T) {
var items []billing.ListCurrentSubscriptionItemsBySubscriptionIDsRow
for _, s := range tc.subs {
for _, r := range tc.rows {
if r.SubscriptionID == s.SubscriptionID {
items = append(items, billing.ListCurrentSubscriptionItemsBySubscriptionIDsRow{SubscriptionID: s.SubscriptionID, ProductID: r.DisplacedProductID})
}
}
if !slices.ContainsFunc(items, func(i billing.ListCurrentSubscriptionItemsBySubscriptionIDsRow) bool {
return i.SubscriptionID == s.SubscriptionID
}) {
items = append(items, billing.ListCurrentSubscriptionItemsBySubscriptionIDsRow{SubscriptionID: s.SubscriptionID, ProductID: "tier-Hosting"})
}
}
if got := subscriptionTitlesFor(tc.subs, items, shapes, tc.rows); !maps.Equal(got, tc.want) {
t.Errorf("subscriptionTitlesFor(%s) = %v, want %v", tc.name, got, tc.want)
}
})
}
}
// TestDisplacementBadge pins the Not delivering badge on both view models:
// the label, the warning tone and the title (Copy rows O1 and O2).
func TestDisplacementBadge(t *testing.T) {
title := "Billing continues while a grant holds Hosting."
want := Badge{Label: "Not delivering", Tone: "warning", Title: title}
if got := (BillingSummaryViewModel{DisplacementTitle: title}).DisplacementBadge(); got != want {
t.Errorf("BillingSummaryViewModel.DisplacementBadge() = %+v, want %+v", got, want)
}
if got := (SubscriptionViewModel{DisplacementTitle: title}).DisplacementBadge(); got != want {
t.Errorf("SubscriptionViewModel.DisplacementBadge() = %+v, want %+v", got, want)
}
}
+23 -3
View File
@@ -64,9 +64,7 @@ var readCountPages = []readCountPage{
}, "/operator/products?q=" + w.Sfx
}},
{name: "billing accounts", perRow: 0, build: billingListPage("accounts", func(w *world.World, a world.Account) {})},
{name: "billing subscriptions", perRow: 0, build: billingListPage("subscriptions", func(w *world.World, a world.Account) {
w.MapSubscription(w.BareSubscription(a, time.Time{}, time.Time{}))
})},
{name: "billing subscriptions", perRow: 0, build: displacedSubscriptionsPage},
{name: "billing invoices", perRow: 0, build: billingListPage("invoices", func(w *world.World, a world.Account) {
w.MapInvoice(w.Invoice(a, world.InvoiceOpts{}))
})},
@@ -343,6 +341,28 @@ func newCountedAccounts(w *world.World, fill func(w *world.World, a world.Accoun
}
}
// displacedSubscriptionsPage is the subscriptions list's table row: each row
// is a subscription a grant displaced, on an organization and a mapped billing
// account of its own, as newCountedAccounts makes them. The product is priced
// and on a ladder of its own, the subscription is made on it (which uses that
// account) and mapped, and a grant of the product goes over it, as the
// organization page's row does, so the page's statement count is pinned at no
// statement per displaced row.
func displacedSubscriptionsPage(w *world.World) (func(), string) {
n := 0
owner := w.Person("Owner")
return func() {
n++
o := w.Org(fmt.Sprintf("Counted %d", n), w.OrgType(fmt.Sprintf("c%d", n)), owner)
w.MapAccount(w.BillingAccount(o, fmt.Sprintf("Account %d", n)))
p := w.Product(fmt.Sprintf("Displaced %d", n))
w.Price(p, 1000)
w.Ladder(fmt.Sprintf("Displaced %d", n), p)
w.MapSubscription(w.Subscribe(o, world.SubOpts{}, p))
w.Issue(o, p, world.IssueOpts{})
}, "/operator/billing/subscriptions?q=" + w.Sfx
}
// billingListPage is the table row of one billing list: each row is an account
// that is mapped, with what fill adds to it.
func billingListPage(list string, fill func(w *world.World, a world.Account)) func(w *world.World) (func(), string) {
@@ -0,0 +1,159 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world"
)
// displacedListFixture is organization A, whose subscription to the tier of
// ladder Box a grant of the same tier displaced, and organization B, whose
// subscription delivers.
type displacedListFixture struct {
w *world.World
orgA, orgB world.Org
subA, subB world.Subscription
title string
}
// newDisplacedListFixture builds the state the grant-over-subscription
// golden builds for A (a subscription, then a grant of its tier over it) and
// a subscription of B on a ladder of its own with no grant. Each account is
// made first, which w.Subscribe then uses, and each subscription is mapped.
func newDisplacedListFixture(t *testing.T) displacedListFixture {
t.Helper()
w := world.New(t, dbtest.Open(t))
f := displacedListFixture{w: w}
f.orgA = w.Org("Displaced", w.OrgType("boxa"), w.Person("Owner A"))
small := w.Product("Small")
w.Price(small, 1000)
box := w.Ladder("Box", small)
w.BillingAccount(f.orgA, "Account A")
f.subA = w.Subscribe(f.orgA, world.SubOpts{}, small)
w.MapSubscription(f.subA)
w.Issue(f.orgA, small, world.IssueOpts{})
f.title = "Billing continues while a grant holds " + box.Name + "."
f.orgB = w.Org("Delivering", w.OrgType("boxb"), w.Person("Owner B"))
rack := w.Product("Rack")
w.Price(rack, 1000)
w.Ladder("Rack", rack)
w.BillingAccount(f.orgB, "Account B")
f.subB = w.Subscribe(f.orgB, world.SubOpts{}, rack)
w.MapSubscription(f.subB)
return f
}
// list loads the subscriptions list under the World's suffix with cfg.
func (f displacedListFixture) list(t *testing.T, cfg OperatorPartialsConfig) (SubscriptionsData, string) {
t.Helper()
request := httptest.NewRequest(http.MethodGet, "/operator/billing/subscriptions?q="+f.w.Sfx, nil)
data := catalogReadsHandler(t, cfg).loadSubscriptionsData(request)
return data, Render(t, OperatorSet(t), "operator_subscriptions.html", data)
}
// titleOf is the row title of the subscription, and whether the list has it.
func titleOf(data SubscriptionsData, id string) (string, bool) {
for _, s := range data.Subscriptions {
if s.SubscriptionID == id {
return s.DisplacementTitle, true
}
}
return "", false
}
// badgeMarkup is the Not delivering badge as the statusBadge part renders it.
func badgeMarkup(title string) string {
return `<span class="badge text-bg-warning" title="` + title + `">Not delivering<span class="visually-hidden">, ` + title + `</span></span>`
}
// TestSubscriptionsListMarksADisplacedSubscription pins the subscriptions
// list's badge (operator-billing-views, "The subscriptions list marks a
// subscription that delivers nothing while another plan holds its ladder"):
// the displaced row carries the title, the delivering row none, the part
// renders the badge once, and the title is the organization page's.
func TestSubscriptionsListMarksADisplacedSubscription(t *testing.T) {
f := newDisplacedListFixture(t)
data, page := f.list(t, catalogReadsConfig(f.w))
if got, ok := titleOf(data, f.subA.ID); !ok || got != f.title {
t.Errorf("the displaced subscription's row title = %q, want %q", got, f.title)
}
if got, ok := titleOf(data, f.subB.ID); !ok || got != "" {
t.Errorf("the delivering subscription's row title = %q, want \"\"", got)
}
if strings.Count(page, badgeMarkup(f.title)) != 1 {
t.Errorf("the list does not render the Not delivering badge on the displaced row:\n%s", page)
}
request := httptest.NewRequest(http.MethodGet, "/operator/organizations/"+f.orgA.ID, nil)
card := catalogReadsHandler(t, catalogReadsConfig(f.w)).loadOrgEnrollmentData(request, f.orgA.ID, "").BillingSummary.DisplacementTitle
if listed, _ := titleOf(data, f.subA.ID); listed != card {
t.Errorf("the list's title %q differs from the organization page's %q", listed, card)
}
}
type subscriptionsListReadFailure struct {
name, querier, method string
}
func subscriptionsListReadFailures() []subscriptionsListReadFailure {
return []subscriptionsListReadFailure{
{"a failed item read", "billing", "ListCurrentSubscriptionItemsBySubscriptionIDs"},
{"a failed shape read", "entitlements", "ListConferralShapes"},
{"a failed displacement read", "entitlements", "ListSubscriptionDisplacementsBySubscriptionIDs"},
}
}
// checkSubscriptionsListAfterFailure fails the test unless the list after a
// failed read keeps its rows without the badge and says so once, above the
// table.
func checkSubscriptionsListAfterFailure(t *testing.T, f displacedListFixture, tc subscriptionsListReadFailure, baselineRows int) {
t.Helper()
data, page := f.list(t, failingEnrollmentConfig(f.w, tc.querier, tc.method))
if data.DisplacementError != "Failed to load deliveries." {
t.Errorf("DisplacementError after a failed %s read = %q, want \"Failed to load deliveries.\"", tc.method, data.DisplacementError)
}
if len(data.Subscriptions) != baselineRows {
t.Errorf("the list after a failed %s read lists %d rows, want %d", tc.method, len(data.Subscriptions), baselineRows)
}
if got, ok := titleOf(data, f.subA.ID); !ok || got != "" {
t.Errorf("the displaced row's title after a failed %s read = %q, want \"\"", tc.method, got)
}
switch n := strings.Count(page, "Failed to load deliveries."); {
case n == 0:
t.Errorf("the list after a failed %s read does not hold \"Failed to load deliveries.\"", tc.method)
case n != 1:
t.Errorf("the list after a failed %s read holds \"Failed to load deliveries.\" %d times, want 1", tc.method, n)
}
if strings.Index(page, "Failed to load deliveries.") > strings.Index(page, "<table") {
t.Errorf("the list after a failed %s read puts \"Failed to load deliveries.\" after the table", tc.method)
}
}
// TestSubscriptionsListDisplacementReadFailures pins that a failed read for
// the badges is never a delivering row: the list keeps its rows, no row
// carries the badge, and one line above the table says the read failed
// (Copy row O4).
func TestSubscriptionsListDisplacementReadFailures(t *testing.T) {
f := newDisplacedListFixture(t)
baseline, _ := f.list(t, catalogReadsConfig(f.w))
if baseline.DisplacementError != "" {
t.Fatalf("the baseline list failed its displacement read: %q", baseline.DisplacementError)
}
if got, _ := titleOf(baseline, f.subA.ID); got != f.title {
t.Fatalf("the baseline list failed its displacement read: the displaced row's title = %q, want %q", got, f.title)
}
for _, tc := range subscriptionsListReadFailures() {
t.Run(tc.name, func(t *testing.T) {
checkSubscriptionsListAfterFailure(t, f, tc, len(baseline.Subscriptions))
})
}
}
@@ -14,7 +14,8 @@
"StripeSubscriptionID": "sub_<subscription:Birch test>",
"StripeSyncStatus": "synced",
"CreatedAt": "<today>",
"EnvState": "test"
"EnvState": "test",
"DisplacementTitle": ""
},
{
"SubscriptionID": "<subscription:Alder unmapped>",
@@ -30,7 +31,8 @@
"StripeSubscriptionID": "",
"StripeSyncStatus": "not_mapped",
"CreatedAt": "<today>",
"EnvState": ""
"EnvState": "",
"DisplacementTitle": ""
},
{
"SubscriptionID": "<subscription:Birch unrecorded>",
@@ -46,7 +48,8 @@
"StripeSubscriptionID": "sub_<subscription:Birch unrecorded>",
"StripeSyncStatus": "synced",
"CreatedAt": "<today>",
"EnvState": ""
"EnvState": "",
"DisplacementTitle": ""
},
{
"SubscriptionID": "<subscription:Alder live>",
@@ -62,10 +65,12 @@
"StripeSubscriptionID": "sub_<subscription:Alder live>",
"StripeSyncStatus": "synced",
"CreatedAt": "<today>",
"EnvState": ""
"EnvState": "",
"DisplacementTitle": ""
}
],
"Error": "",
"DisplacementError": "",
"EnvNotice": {
"Text": "Showing all environments.",
"LinkLabel": "Show live only",
@@ -14,7 +14,8 @@
"StripeSubscriptionID": "",
"StripeSyncStatus": "not_mapped",
"CreatedAt": "<today>",
"EnvState": ""
"EnvState": "",
"DisplacementTitle": ""
},
{
"SubscriptionID": "<subscription:Birch unrecorded>",
@@ -30,7 +31,8 @@
"StripeSubscriptionID": "sub_<subscription:Birch unrecorded>",
"StripeSyncStatus": "synced",
"CreatedAt": "<today>",
"EnvState": ""
"EnvState": "",
"DisplacementTitle": ""
},
{
"SubscriptionID": "<subscription:Alder live>",
@@ -46,10 +48,12 @@
"StripeSubscriptionID": "sub_<subscription:Alder live>",
"StripeSyncStatus": "synced",
"CreatedAt": "<today>",
"EnvState": ""
"EnvState": "",
"DisplacementTitle": ""
}
],
"Error": "",
"DisplacementError": "",
"EnvNotice": {
"Text": "1 subscription from test mode is not shown.",
"LinkLabel": "Show all",
+1 -1
View File
@@ -7,7 +7,7 @@
"SubscriptionCount": 0,
"SubscriptionState": "",
"SubscriptionNote": "",
"DisplacementNote": "",
"DisplacementTitle": "",
"DisplacementError": "",
"HasInvoice": false,
"LatestInvoiceID": "",
@@ -52,12 +52,12 @@
<div>
<span class="badge text-bg-success">Active</span>
<span class="badge text-bg-warning" title="Billing continues while a grant holds Box.">Not delivering<span class="visually-hidden">, Billing continues while a grant holds Box.</span></span>
<a href="/operator/billing/subscriptions?q=grant-over-subscription&#43;">1 subscription</a>
<small class="text-muted">· renews Jan 15, 2099</small>
</div>
<div class="text-muted small">Delivers nothing on Box, which a grant holds</div>
</div>
<div class="col-md-4">
@@ -7,7 +7,7 @@
"SubscriptionCount": 1,
"SubscriptionState": "active",
"SubscriptionNote": "renews Jan 15, 2099",
"DisplacementNote": "Delivers nothing on Box, which a grant holds",
"DisplacementTitle": "Billing continues while a grant holds Box.",
"DisplacementError": "",
"HasInvoice": false,
"LatestInvoiceID": "",
+1 -1
View File
@@ -7,7 +7,7 @@
"SubscriptionCount": 0,
"SubscriptionState": "",
"SubscriptionNote": "",
"DisplacementNote": "",
"DisplacementTitle": "",
"DisplacementError": "",
"HasInvoice": false,
"LatestInvoiceID": "",
+1 -1
View File
@@ -7,7 +7,7 @@
"SubscriptionCount": 0,
"SubscriptionState": "",
"SubscriptionNote": "",
"DisplacementNote": "",
"DisplacementTitle": "",
"DisplacementError": "",
"HasInvoice": false,
"LatestInvoiceID": "",
+1 -1
View File
@@ -7,7 +7,7 @@
"SubscriptionCount": 0,
"SubscriptionState": "",
"SubscriptionNote": "",
"DisplacementNote": "",
"DisplacementTitle": "",
"DisplacementError": "",
"HasInvoice": false,
"LatestInvoiceID": "",
+1 -1
View File
@@ -7,7 +7,7 @@
"SubscriptionCount": 0,
"SubscriptionState": "",
"SubscriptionNote": "",
"DisplacementNote": "",
"DisplacementTitle": "",
"DisplacementError": "",
"HasInvoice": false,
"LatestInvoiceID": "",
@@ -185,7 +185,7 @@
<form id="form-operator.enrollment.grant.issue" data-form="operator.enrollment.grant.issue" data-form-kind="create" class="app-form" novalidate hx-post="/partials/operator/organizations/<org:rich>/grant/create" hx-target="#operator-body" hx-swap="innerHTML" hx-disable="find button[type=submit]" hx-indicator="find .htmx-indicator">
<div id="form-operator.enrollment.grant.issue-error" class="app-form-error" role="alert"></div>
<div class="app-form-message mb-2">A tier product moves the pool to that tier, up or down.<div class="alert alert-warning py-1 px-2 small mt-2 mb-0" role="alert">Products marked ⚠ would supersede a tier currently held by a subscription; the subscription keeps billing until it is changed in the billing view.</div></div>
<div class="app-form-message mb-2">A tier product moves the pool to that tier, up or down.<div class="alert alert-warning py-1 px-2 small mt-2 mb-0" role="alert">Products marked ⚠ would supersede a tier a subscription pays for; the subscription keeps billing and delivers nothing on that ladder until the grant ends.</div></div>
@@ -7,7 +7,7 @@
"SubscriptionCount": 1,
"SubscriptionState": "canceling",
"SubscriptionNote": "ends Jan 15, 2099",
"DisplacementNote": "",
"DisplacementTitle": "",
"DisplacementError": "",
"HasInvoice": true,
"LatestInvoiceID": "<invoice:First>",
+1 -1
View File
@@ -185,7 +185,7 @@
<form id="form-operator.enrollment.grant.issue" data-form="operator.enrollment.grant.issue" data-form-kind="create" class="app-form" novalidate hx-post="/partials/operator/organizations/<org:rich>/grant/create" hx-target="#operator-body" hx-swap="innerHTML" hx-disable="find button[type=submit]" hx-indicator="find .htmx-indicator">
<div id="form-operator.enrollment.grant.issue-error" class="app-form-error" role="alert"></div>
<div class="app-form-message mb-2">A tier product moves the pool to that tier, up or down.<div class="alert alert-warning py-1 px-2 small mt-2 mb-0" role="alert">Products marked ⚠ would supersede a tier currently held by a subscription; the subscription keeps billing until it is changed in the billing view.</div></div>
<div class="app-form-message mb-2">A tier product moves the pool to that tier, up or down.<div class="alert alert-warning py-1 px-2 small mt-2 mb-0" role="alert">Products marked ⚠ would supersede a tier a subscription pays for; the subscription keeps billing and delivers nothing on that ladder until the grant ends.</div></div>
+1 -1
View File
@@ -7,7 +7,7 @@
"SubscriptionCount": 1,
"SubscriptionState": "canceling",
"SubscriptionNote": "ends Jan 15, 2099",
"DisplacementNote": "",
"DisplacementTitle": "",
"DisplacementError": "",
"HasInvoice": true,
"LatestInvoiceID": "<invoice:First>",
+1 -1
View File
@@ -7,7 +7,7 @@
"SubscriptionCount": 0,
"SubscriptionState": "",
"SubscriptionNote": "",
"DisplacementNote": "",
"DisplacementTitle": "",
"DisplacementError": "",
"HasInvoice": false,
"LatestInvoiceID": "",
@@ -140,7 +140,7 @@
<form id="form-operator.enrollment.grant.issue" data-form="operator.enrollment.grant.issue" data-form-kind="create" class="app-form" novalidate hx-post="/partials/operator/organizations/<org:suspended-subscription>/grant/create" hx-target="#operator-body" hx-swap="innerHTML" hx-disable="find button[type=submit]" hx-indicator="find .htmx-indicator">
<div id="form-operator.enrollment.grant.issue-error" class="app-form-error" role="alert"></div>
<div class="app-form-message mb-2">A tier product moves the pool to that tier, up or down.<div class="alert alert-warning py-1 px-2 small mt-2 mb-0" role="alert">Products marked ⚠ would supersede a tier currently held by a subscription; the subscription keeps billing until it is changed in the billing view.</div></div>
<div class="app-form-message mb-2">A tier product moves the pool to that tier, up or down.<div class="alert alert-warning py-1 px-2 small mt-2 mb-0" role="alert">Products marked ⚠ would supersede a tier a subscription pays for; the subscription keeps billing and delivers nothing on that ladder until the grant ends.</div></div>
@@ -7,7 +7,7 @@
"SubscriptionCount": 1,
"SubscriptionState": "past_due",
"SubscriptionNote": "",
"DisplacementNote": "",
"DisplacementTitle": "",
"DisplacementError": "",
"HasInvoice": false,
"LatestInvoiceID": "",
@@ -8,9 +8,9 @@ The subscription reconcile, `ReconcileSubscription` (`internal/fulfillment/recon
`core.grant_replacements` (`00023:44-68`) already pairs exactly those two rows, for an ended grant provision, and `core.grant_waits` and resumption read it; its tests are `internal/entitlements/grant_waits_test.go:69-150`. `core.superseded_ends` (`00025_superseded_transitions.sql:75-90`) pairs on pool, instant, actor and reason with no ladder, and reads `reason`, which the reader role withholds (`scripts/db-reader-role.sql:95`). `openspec/specs/grant-resumption/spec.md:38` forbids adding a column to the position tables or changing the conferral functions' supersession to record the relation. `pool_provision_ladders.default_sourced` (`00026_outgoing_default_rule.sql:23-26`) marks a position whose grant is a default grant.
The issue form warns that a grant would supersede a subscription's tier and says "the subscription keeps billing until it is changed in the billing view" (`internal/server/operator_enrollment_forms.go:132`); every `/operator/billing/*` route is a read (`internal/server/testdata/routes.golden:61-73`). The plan-enrollment requirement for that warning forbids changing the subscription as a side effect of the grant (`openspec/specs/plan-enrollment-administration/spec.md:364`). The suspension requirement asks the condition to be surfaced "via the operator-facing audit trail and an operator warning" (`openspec/specs/stripe-subscription-management/spec.md:355`, `:374`).
The issue form warns that a grant would supersede a subscription's tier and says "the subscription keeps billing until it is changed in the billing view" (`internal/server/operator_enrollment_forms.go:132`); every `/operator/billing/*` route is a read (`internal/server/testdata/routes.golden:61-73`), so the sentence sends the operator to views that cannot change a subscription; section 8 rewords it (D4). The plan-enrollment requirement for that warning forbids changing the subscription as a side effect of the grant (`openspec/specs/plan-enrollment-administration/spec.md:364`). The suspension requirement asks the condition to be surfaced "via the operator-facing audit trail and an operator warning" (`openspec/specs/stripe-subscription-management/spec.md:355`, `:374`).
On the organization page the subscription is read once: the Billing card's Subscriptions row, built by `summarizeSubscriptions` (`internal/server/operator_enrollment.go:400-431`) from every subscription of the account (`loadOrgBillingSummary`, `:1567-1606`). For one subscription the row is that subscription's state badge and its period clause; for several it is a count, the worst state and a note counting those in it. The card names no product (`operator_enrollment.go:335-339`); the per-subscription list it links to, `/operator/billing/subscriptions` (`internal/server/operator_billing.go:428-590`), lists subscriptions across organizations and reads no pool.
On the organization page the subscription is read once: the Billing card's Subscriptions row, built by `summarizeSubscriptions` (`internal/server/operator_enrollment.go:400-431`) from every subscription of the account (`loadOrgBillingSummary`, `:1567-1606`). For one subscription the row is that subscription's state badge and its period clause; for several it is a count, the worst state and a note counting those in it. The card names no product (`operator_enrollment.go:335-339`); the per-subscription list it links to, `/operator/billing/subscriptions` (`internal/server/operator_billing.go:428-590`), lists subscriptions across organizations and reads no pool. Its loader reads one page of subscriptions (`ListSubscriptionsPage`) and the page's Stripe mappings in one statement (`operator_billing.go:494-590`); a row's Status cell renders the state badge and, for a subscription set to cancel, the Canceling badge with its title (`internal/embeds/templates/partials/operator_subscriptions.html:49-52`; `internal/server/anatomy.go:1063-1067`).
What the operator and the member see today before the next reconcile, the state this change keeps: the Pools panel lists the grant's delivery only; the Billing card reads the subscription as active with its renewal; the grants ledger reads the grant live; Tier changes reads the operator's upgrade or downgrade. The member's Products page shows the grant's tier as current with no price line and no Cancel plan, and invoices keep arriving. After the next reconcile, today: the subscription's tier is back, the grant reads inactive (unmarked) or on hold waiting on the subscription (marked).
@@ -20,12 +20,12 @@ What the operator and the member see today before the next reconcile, the state
- A grant that took a subscription's ladder keeps it until it ends, through every reconcile path, recovery from past due included.
- The subscription keeps billing, delivers nothing there while another plan holds the ladder, and delivers again without anyone's act once no other plan does.
- A subscription that pays for two tiers of one ladder, and two subscriptions on one ladder, stop alternating.
- The operator sees, on the organization page, which ladder a subscription delivers nothing on and what kind of plan holds it.
- The operator sees, on the organization page and in the subscriptions list, that a subscription bills and delivers nothing, on which ladder, and what kind of plan holds it; the issue form says so before the grant is issued.
**Non-Goals:**
- Any change at Stripe (D3).
- A member-facing fact, and the issue form's sentence (D4, out of scope).
- Delivering again in the act that ends the holder (D10, out of scope).
- A member-facing fact (D4, #269).
- Delivering again in the act that ends the holder (D10, #270).
- The ended branch's lock order (#180).
- The pool row lock itself, which `reconcile-takes-pool-lock` (#193) takes and this change depends on (D9).
@@ -137,7 +137,7 @@ Pick: the transition pair, stated once in a view, and one SQL function over it.
Its rules: the subscription's latest episode on each ladder of the pool, by `activated_at` and then the episode's id; only when that episode is `ended`; its `end` row found through the pair by provision, ladder and `effective_at = ended_at`; a successor whose provision is not this subscription's (D12); and a holder (D2). The episode is matched by provision, ladder and instant, the key the view pairs on, rather than by the transition's `provision_ladder_id`; every transition row that names a provision carries its episode id since `00004`'s backfill, and the constraint `chk_pool_provision_transitions_occupancy_named` refuses a new one without it, so the two matches agree on every row the schema holds. It grants `EXECUTE` to `core_reader`, `core_writer` and `core_owner`, as `00023:359-360` grants `core.grant_waits`; `console_runtime` reaches it through `core_writer` (`00029`).
Reason: the record exists for every supersession ever written, so history needs no backfill; the grant-resumption spec forbids the column; the end row's actor cannot tell a second subscription (both rows are `webhook`) from the subscription's own tier change, and `core.superseded_ends` has no ladder or source and reads `reason`. A rule that SQL and Go both need lives in one of them (`docs/go-conventions.md`, "Where a rule and an act live"): the reconcile, the organization page and a later D10 act read the function, and none restates it. When #179 makes transitions point at their act, only the pairs view's body changes, as `00025:15-17` plans for `core.superseded_ends`.
Reason: the record exists for every supersession ever written, so history needs no backfill; the grant-resumption spec forbids the column; the end row's actor cannot tell a second subscription (both rows are `webhook`) from the subscription's own tier change, and `core.superseded_ends` has no ladder or source and reads `reason`. A rule that SQL and Go both need lives in one of them (`docs/go-conventions.md`, "Where a rule and an act live"): the reconcile, the organization page, the subscriptions list and a later D10 act read the function, and none restates it. When #179 makes transitions point at their act, only the pairs view's body changes, as `00025:15-17` plans for `core.superseded_ends`.
Adversarial check: a false pair needs a transaction that ends the subscription's episode on a ladder and writes another source's typed row on that ladder with a from side, by the same actor at the same instant, without a supersession. The ended branch's resumption and floor write `system` rows with `from_rank` NULL; a tier removal's floor finds the subscription's episode already ended, so its row is an `initiate`; the plan-act moves touch default-held positions only (`internal/entitlements/plan_act_move.go:130-217`). A false negative needs the two rows of one supersession to differ in instant or actor, which `core.confer` cannot write.
@@ -150,7 +150,7 @@ Pick: displacement and a holder. The reconcile leaves a missing desired product
- The check runs per ladder, not per product, so a period-end downgrade that lands while displaced (another product of the same ladder) is held back too (F16).
- A desired product with a live provision is not checked: its episodes are live, so it is not displaced, and its bounds update as today.
- Two reads run once per item pass, after the live read and under the pool row: `ListConferralShapes` (the desired products' ladders and ranks, from `core.product_conferral_shapes`) and `ListSubscriptionDisplacements` (the function, with each ladder's name). They run whatever is missing, so every active reconcile's trace has the same statements.
- The rule is a Go function that reads no row, `DisplacedProducts(missing []string, rungs map[string][]Rung, displacements []Displacement) map[string]Displacement`, in package `fulfillment`, so a unit test covers it and the organization page calls it too (D4): a rule that a page shows and the reconcile enforces is one function that reads no row (`docs/go-conventions.md`, "Where a rule and an act live").
- The rule is a Go function that reads no row, `DisplacedProducts(missing []string, rungs map[string][]Rung, displacements []Displacement) map[string]Displacement`, in package `fulfillment`, so a unit test covers it and the organization page and the subscriptions list call it too (D4): a rule that a page shows and the reconcile enforces is one function that reads no row (`docs/go-conventions.md`, "Where a rule and an act live").
Reason: the subscription pays, so it delivers whenever no other deliberate plan holds its ladder, and a default is the floor, never a deliberate holder (`docs/models/plan-ladders-transitions.md`, invariant 11). Displacement alone would leave a subscription that bills delivering nothing forever once the grant ends (F11, F12), where today's next reconcile at least brings it back. Occupancy alone would break a purchase over a grant, which the offer sells (rule 13, `internal/plans/offer.go:181`) and invariant 19 relies on (F17, F18). The displacer's chain ends a grant an operator issues later over the default (F13), the same defect for that grant. Accepted edge: a legacy grant from a default change, re-decreed over the default while the subscription was displaced, holds the ladder against it (F25); it is reachable only through the gap D10 would close, and it lasts until an operator revokes the legacy grant, which has no end date.
@@ -160,19 +160,53 @@ The subscription keeps billing; the reconcile changes nothing at Stripe. #212 sa
### D4. What the operator sees
Options: (a) nothing beyond the issuance warning; (b) an operator fact on the organization page where the subscription is read, read from D1's function; (c) (b) and a member fact; (d) the issue form's sentence reworded.
Options: (a) nothing beyond the issuance warning; (b) an operator fact where the subscription is read, read from D1's function; (c) (b) and a member fact; (d) the issue form's sentence reworded.
Pick: (b), on the Billing card's Subscriptions row, as one muted line under the row's value line (Copy table, rows O1 to O3). (c) and (d) are out of scope, waiting on a product decision: (c) needs an answer on whether a member can cancel a displaced subscription in the console, which today no plan move can (`plan_change.go:770`), and (d) touches the advisory requirement `suspended-plans-stay-held` modifies.
Pick: (b) and (d). (b) is a `Not delivering` badge in the warning tone, rendered through the `statusBadge` part beside the subscription's state badge, on the organization page's Billing card and on the subscription's row in the operator's subscriptions list, with a title that names the ladders and the holder (Copy rows O1 and O2). (d) is the issue form's warning saying what the grant does to the subscription (Copy row O5). (c) stays out of scope (#269), decided there as a change of its own that pauses the subscription's collection at Stripe while a grant holds the ladder.
Where: the organization page reads subscriptions in one place, the Billing card's Subscriptions row, which is where an operator reads a displaced subscription today, as active with its renewal. The row is built from each of the account's subscriptions, and for one subscription, the case Checkout makes, it is that subscription's own line (its state and its period). The subscriptions list's row is read per subscription too, but it lives on `/operator/billing/subscriptions`, a list across organizations that reads no pool and is not the organization page; a fact there needs the function once per listed row. So the line goes on the card, beside the Pools panel that shows the delivery holding the ladder.
**Replaced on review.** Sections 1 to 6 shipped (b) as one muted line under the Subscriptions row's value line ("Delivers nothing on Hosting, which a grant holds", the first draft's Copy rows O1 and O2), on the card only, and left (d) out. On review the maintainer was shown four forms for the fact and picked the badge, on the card and in the list, and (d) in the same section (section 8). The reason: the state means that a member is charged for a plan the console does not deliver. Whether that is right depends on why the grant was issued, which only the operator knows, so the state must stand out the way Past due does, not sit in muted text. A badge with a title is the form the same row and the list already use for a subscription set to cancel (`SubscriptionsBadge`, `internal/server/anatomy.go:720-726`; `CancelingBadge`, `anatomy.go:1063-1067`). The muted line is removed, not kept beside the badge: the badge and its title carry the same facts, and two marks of one state on one row would repeat it. The same review removed `paused` from the statuses the badge admits (F33). (d) was out of scope in the first draft because it touched the requirement `suspended-plans-stay-held` modified; that change has archived, so this change modifies the requirement from its current text. The reasoning below on where the card reads the rule and how is the first draft's, unchanged except where it names the line.
What it says: the ladders the subscription delivers nothing on, by name, and the kind of plan that holds them (a grant, another subscription, another plan), never a product, because the card names none (`plan-enrollment-administration`, "The composite's billing card summarizes the money"). For a product of several ladders the line names only the ladder the function picked, the held one, though the whole product delivers nothing (F21); that is enough for now. With several subscriptions on the account, the line counts the displaced ones, as the row's note counts those in the worst state. The badge and the period clause already say the subscription bills; the line adds that it delivers nothing and why, which the operator cannot read anywhere else on the page: a cost the member is paying is a fact under the copy rules (`AGENTS.md`, "UI copy").
Where, the card: the organization page reads subscriptions in one place, the Billing card's Subscriptions row, which is where an operator reads a displaced subscription today, as active with its renewal. The row is built from each of the account's subscriptions, and for one subscription, the case Checkout makes, it is that subscription's own line (its state and its period). The badge sits directly after the row's state badge and before the count, as the maintainer previewed it: `[Active] [Not delivering] 1 subscription · renews Nov 26, 2026`.
How: the card states the rule the way the reconcile does, not the function alone. The function reads episodes, pairs and holders, never the subscription's current items, so after a Stripe-side edit that moves the subscription's item to a product on another ladder, or removes it, the function still returns the old ladder while the reconcile has stopped leaving anything alone. The card therefore calls the reconcile's pure function, `fulfillment.DisplacedProducts` (D2), over the subscription's current items and their rungs, and names the displaced ladders of the products it leaves out; a displacement whose ladder no current item names produces nothing. The reconcile passes the products with no live provision as the missing set; the card passes all the current products, which gives the same answer, because a product with a live provision has live episodes and is on no displaced ladder (D2).
Where, the list: the first draft kept the subscriptions list out, because a fact there needs the function once per listed row. One grouped read per page answers that (below), and the list is where an operator who reads subscriptions across organizations sees the same displaced subscription as Active with its period. The badge sits in the row's Status cell after the state badge and the Canceling badge, the cell's existing exception marker.
`loadOrgBillingSummary` runs three statements for the account, none per subscription (`docs/go-conventions.md`, a read page derives what it shows from data it loaded; `TestOperatorPagesReadAFixedNumberOfTimes` pins every operator page at no statement per row): `ListAccountSubscriptionDisplacements`, which applies the function to every subscription of the account with the organization's default pool (`FROM core.subscriptions s CROSS JOIN LATERAL core.subscription_displacements(s.subscription_id, ...)`), `ListCurrentSubscriptionItemsByBillingAccountID` in `internal/billing/queries/subscription_items.sql`, which selects `si.subscription_id` and `si.product_id` from `core.subscription_items si JOIN core.subscriptions s USING (subscription_id)` where `s.billing_account_id = sqlc.arg(billing_account_id)::uuid AND si.ended_at IS NULL`, ordered `si.subscription_id, si.product_id` (the rows `mirrorItems` writes), and `ListConferralShapes` for those products. The default pool comes from the pools `loadOrgPools` already read, not from a second read of the row. A failed pools read sets `data.Error`, and the page then renders that error alone (`operator_enrollment.html:14-18`), so the card is not drawn. The ladder names come from the displacement query's join to `core.plan_ladders`, one statement. The status filter stays in Go (`subscriptionMayBeDisplaced`) over the subscriptions the card already read, which are `active`, `trialing`, `past_due`, `unpaid` or `paused`. A pure function `displacementNote(subscriptions int, displaced []displacedSubscription) string` builds the line, and `BillingSummaryViewModel` carries it as `DisplacementNote`.
What it says: the label `Not delivering` states the fact. The title says that billing goes on and which kind of plan holds which ladders, naming the ladders by display name and never a product, because the card names none (`plan-enrollment-administration`, "The composite's billing card summarizes the money"). For a product of several ladders the title names only the ladder the function picked, the held one, though the whole product delivers nothing (F21); that is enough for now. The title forms, each one sentence (Copy row O2):
A failed read is never an answer (`docs/go-conventions.md`): a failed items, shapes or displacement read logs (Copy, log row L3) and sets `BillingSummaryViewModel.DisplacementError`, which the template renders in the line's place (Copy, row O3), as the page already shows `data.MembersError` for the members section. The card's other silent reads are #207's defects, not a pattern this change extends.
- The card of an account with one subscription, and every row of the list: `Billing continues while <holder> <ladders>.` The list words each row in this form, because a row is one subscription.
- The card of an account with `<n>` subscriptions, `<k>` of them displaced: `Billing continues on <k> of <n> subscriptions while <holder> <ladders>.` The card's badge is account-level, as the row's state badge is. `<k>` counts the displaced subscriptions the badge renders for. `<n>` is every subscription on the account, the number the row's link reads ("2 subscriptions"), so the title reads against the count beside it; `<k>` can equal `<n>`. `<ladders>` are the displaced ladders of all `<k>` subscriptions, and `<holder>` covers the holder provisions of all of them.
- `<holder>` is `a grant holds`, `another subscription holds` or `another plan holds` when one provision holds every ladder the title names, by that provision's source (`another plan` for a purchase, which no console path writes), and `other plans hold` when several provisions hold them: section 6's phrases (`displacementHolder`), unchanged.
- `<ladders>` are the distinct ladders, by id, sorted by display name with `strings.Compare` (then by id), joined by `joinWithAnd`: `Hosting`, `Hosting and Support`, `Docs, Hosting and Support`.
On the card and in the list, the badge renders only for a subscription whose status `subscriptionMayBeDisplaced` admits (`active`, `trialing`, `past_due` or `unpaid`) and that is displaced through the reconcile's choice over its current items. A `paused` subscription is not admitted: Stripe creates no invoice for it, so a title saying billing continues would be false, and the badge appears once the subscription is active again.
For example: `Billing continues while a grant holds Hosting.`, `Billing continues while other plans hold Hosting and Support.`, `Billing continues on 1 of 2 subscriptions while another subscription holds Hosting.` The state badge and the period clause already say the subscription bills; the badge and its title add that it delivers nothing and which plan holds the ladder, which the operator cannot read anywhere else on the page: a cost the member is paying is a fact under the copy rules (`AGENTS.md`, "UI copy"). Section 8's wording, "Billing continues" in the title and the grant form's warning, is true of every subscription it names except a `paused` one, whose suspended position the issue form also marks (F34), until the change for #269, which pauses a displaced subscription's collection at Stripe; that change switches the badge title and the warning to say billing is paused and replaces this wording. The `statusBadge` part renders the title twice, as the hover and as visually hidden text after the label (`docs/design-system.md` §6); a touch screen shows no hover, the cost grant-resumption D8 accepted, and the label alone still names the state.
How: both places state the rule the way the reconcile does, not the function alone. The function reads episodes, pairs and holders, never the subscription's current items, so after a Stripe-side edit that moves the subscription's item to a product on another ladder, or removes it, the function still returns the old ladder while the reconcile has stopped leaving anything alone. The card therefore calls the reconcile's pure function, `fulfillment.DisplacedProducts` (D2), over the subscription's current items and their rungs, and names the displaced ladders of the products it leaves out; a displacement whose ladder no current item names produces nothing. The reconcile passes the products with no live provision as the missing set; the card passes all the current products, which gives the same answer, because a product with a live provision has live episodes and is on no displaced ladder (D2). The list does the same for each of its rows.
Reads, the card: `loadOrgBillingSummary` runs three statements for the account, none per subscription (`docs/go-conventions.md`, a read page derives what it shows from data it loaded; `TestOperatorPagesReadAFixedNumberOfTimes` pins every operator page at no statement per row): `ListAccountSubscriptionDisplacements`, which applies the function to every subscription of the account with the organization's default pool (`FROM core.subscriptions s CROSS JOIN LATERAL core.subscription_displacements(s.subscription_id, ...)`), `ListCurrentSubscriptionItemsByBillingAccountID` in `internal/billing/queries/subscription_items.sql`, which selects `si.subscription_id` and `si.product_id` from `core.subscription_items si JOIN core.subscriptions s USING (subscription_id)` where `s.billing_account_id = sqlc.arg(billing_account_id)::uuid AND si.ended_at IS NULL`, ordered `si.subscription_id, si.product_id` (the rows `mirrorItems` writes), and `ListConferralShapes` for those products. The default pool comes from the pools `loadOrgPools` already read, not from a second read of the row. A failed pools read sets `data.Error`, and the page then renders that error alone (`operator_enrollment.html:14-18`), so the card is not drawn. The ladder names come from the displacement query's join to `core.plan_ladders`, one statement. The status filter stays in Go (`subscriptionMayBeDisplaced`) over the subscriptions the card already read.
Reads, the list: `loadSubscriptionsData` runs three statements for the page, after its page query and only when the page lists a subscription, none per row (the read-count row `billing subscriptions`): `ListCurrentSubscriptionItemsBySubscriptionIDs` in `internal/billing/queries/subscription_items.sql`, the current items (`si.ended_at IS NULL`) of the page's subscriptions, ordered `si.subscription_id, si.product_id`; `ListConferralShapes` for those products; and `ListSubscriptionDisplacementsBySubscriptionIDs` in `internal/entitlements/queries/pool_provision_transitions.sql`, which applies the function to each of the page's subscriptions with its own organization's default pool:
```sql
FROM core.subscriptions s
JOIN core.accounts a ON a.billing_account_id = s.billing_account_id
JOIN core.resource_pools rp
ON rp.org_id = a.org_id AND rp.pool_type = 'default' AND rp.status = 'active'
CROSS JOIN LATERAL core.subscription_displacements(s.subscription_id, rp.pool_id)
AS d(plan_ladder_id, displaced_product_id, displaced_at, holder_provision_id, holder_source)
JOIN core.plan_ladders pl ON pl.plan_ladder_id = d.plan_ladder_id::uuid
WHERE s.subscription_id = ANY(sqlc.arg(subscription_ids)::uuid[])
```
The pool predicate is the one `GetDefaultPoolByOrgID` states, by which the reconcile resolves its pool (`reconcile.go:200`), and `uq_resource_pools_one_default_per_org` (`00010_schema_hardening.sql:13-14`) allows one default pool per organization, so each subscription joins at most one pool. The list reaches the organization through the subscription's billing account, which the reconcile writes from the same customer mapping it resolves the pool by (`reconcile.go:349-354`). A subscription whose organization has no active default pool gets no row, as the card reads nothing without one, and its reconcile fails before the item pass. The query reads `core.accounts` from the entitlements queries, which already read `core.subscriptions` (`ListAccountSubscriptionDisplacements`): one join pairs each subscription with its organization, where passing the page's organization ids beside its subscription ids would leave the pairing to two parallel arrays.
Two sets of reads, one rule. The card keeps its three statements, because it already holds its pool from `loadOrgPools` and `TestOrgEnrollmentReadFailures` and the read-count row pin them. The list and the card cannot disagree about a subscription, because every step after the reads is shared: the function, `ListConferralShapes`, the conversion of each query's rows to `fulfillment.Displacement` by subscription (`displacementsBySubscription`, one converter over the two sqlc row types, which Go converts one into the other), `fulfillment.RungsByProduct`, `displacedOnPage` (`fulfillment.DisplacedProducts`), the status filter and the grouping by subscription (`displacedByID`), and `displacementTitle`; each item query states the current-item filter itself; `TestSubscriptionsListMarksADisplacedSubscription` compares the list's title with the card's for one organization. A later change can move the card onto the list's two queries without changing what either shows.
Code: `BillingSummaryViewModel.DisplacementNote` is renamed `DisplacementTitle`, and `displacementNote` becomes `displacementTitle(subscriptions int, displaced []displacedSubscription) string`, which builds the forms above and reads no row; `displacementLine` and `displacementLineFor` become `accountDisplacementTitle` and `displacementTitleFor`. The grouping loop of `displacementLineFor` moves into `displacedByID`, reading no row, which the card and the list call. `SubscriptionViewModel` gains `DisplacementTitle` and `SubscriptionsData` gains `DisplacementError`. The badge is a new state key, `not_delivering`, in `badgeMap` (`Not delivering`, `warning`), built in one place, `notDeliveringBadge(title string) Badge`, and offered as `DisplacementBadge()` on both view models.
A failed read is never an answer (`docs/go-conventions.md`). On the card, a failed items, shapes or displacement read logs (Copy, log row L3) and sets `BillingSummaryViewModel.DisplacementError`, which the template renders under the row's value line (Copy, row O3), with no badge, as the page already shows `data.MembersError` for the members section. O3 stays as section 6 wrote it, in the danger style with `role="alert"`: a failed read is not a state of a subscription, and a badge names a state, so a badge cannot carry it. In the list, a failed items, shapes or displacement read logs (L4), sets `SubscriptionsData.DisplacementError` to the same text (O4), which the page renders once, above the table, and no row carries the badge. The rows still render: the page read their state, period and sync status, and those are true, so failing the whole list through its `Error` would hide them for a fact only the badges need. One line, not a mark on each row, because every row's badge failed alike. The card's other silent reads, and the list's silent mapping read (`mappingsByID`, `operator_billing.go:200-210`), are #207's defects, not a pattern this change extends.
The issue form's warning (d): O5 replaces "the subscription keeps billing until it is changed in the billing view", which sent the operator to views whose routes are all reads. The new sentence states the two facts the operator chooses by issuing: the subscription keeps billing, and it delivers nothing on that ladder. "Until the grant ends" is the bound the operator controls; the subscription delivers again at its first reconcile after the grant ends (D10), and not while another plan then holds the ladder. The gap is at most a renewal period and is #270's to close.
### D5. A subscription that pays for two tiers of one ladder
@@ -206,11 +240,11 @@ What this change relies on: the displacement read and the live read run under th
### D10. When a displaced subscription delivers again
In this change: at its next reconcile once no other plan holds the ladder (F11, F12), as today. The more correct moment is the act that ends the holder, before the floor; that is a second resumption mechanism with its own conditions and tests, so it is a candidate issue (proposal, Out of scope). It must take no subscription lock (Locks and transactions).
In this change: at its next reconcile once no other plan holds the ladder (F11, F12), as today. The more correct moment is the act that ends the holder, before the floor; that is a second resumption mechanism with its own conditions and tests, so it is an issue of its own, #270 (proposal, Out of scope). It must take no subscription lock (Locks and transactions).
### D11. A multi-ladder product displaced on one ladder
Leave the whole product alone; the vacated rung stays vacant until the holder ends (F21). `core.confer` cannot confer part of a bundle, and invariant 7 makes the whole-bundle end the model. The issue form's warning already says the grant supersedes the subscription's tier.
Leave the whole product alone; the vacated rung stays vacant until the holder ends (F21; #271). `core.confer` cannot confer part of a bundle, and invariant 7 makes the whole-bundle end the model. The issue form's warning already says the grant supersedes the subscription's tier.
### D12. Two subscriptions on one ladder
@@ -254,11 +288,16 @@ No compatibility code: the rows the defect wrote are fixed by hand after a read-
| F22 | A tier removal or alignment ended the subscription's rung | Not a supersession; the next reconcile confers per the product's new shape | Unchanged | The removal's floor row has no `from_rank`, so no pair (D1) |
| F23 | An operator issues a grant while the reconcile is between its live read and `core.confer` | `core.confer` waits on the pool row, then supersedes the grant that just committed | The reconcile reads after it holds the pool row and sees the grant | D9 |
| F24 | A displaced subscription goes `past_due` | Nothing live to suspend; no Payment due card, since the ladder's position is the grant's | Unchanged | #148 |
| F25 | A legacy grant, re-decreed over the default by a default change, holds L before the displaced subscription's next reconcile | Superseded | Holds the ladder against the subscription, as any non-default grant, indefinitely: the grandfather move writes a legacy grant with no end date, so the subscription bills and delivers nothing until an operator revokes it, and the Billing card's line says a grant holds the ladder | D2; reachable through the F11 gap only |
| F25 | A legacy grant, re-decreed over the default by a default change, holds L before the displaced subscription's next reconcile | Superseded | Holds the ladder against the subscription, as any non-default grant, indefinitely: the grandfather move writes a legacy grant with no end date, so the subscription bills and delivers nothing until an operator revokes it, and the Not delivering badge's title says a grant holds the ladder | D2; reachable through the F11 gap only |
| F26 | A status change (suspension or recovery) while an operator's grant act on the same pool is uncommitted | No pool lock: the last commit writes entitlements that miss one change (#193) | Closed by `reconcile-takes-pool-lock`, not by this change | D9 |
| F27 | A subscription never displaced, canceled, or in its ordinary reconciles | Unchanged | Unchanged, plus the two reads | |
| F28 | The organization page of an account whose subscription is displaced | The Billing card reads Active and "renews {date}", nothing else | The same, and the line "Delivers nothing on {ladder}, which a grant holds" | D4 |
| F29 | The displaced subscription's item is moved at Stripe to a product on another ladder, or removed, and the organization page is read before the next reconcile | The Billing card has no line | No line: the card applies the reconcile's choice over the current items, so it never says "Delivers nothing" for a ladder the subscription no longer pays for | D4 |
| F28 | The organization page of an account whose subscription is displaced | The Billing card reads Active and "renews {date}", nothing else | The same, with a Not delivering badge after Active, titled "Billing continues while a grant holds {ladder}." | D4 |
| F29 | The displaced subscription's item is moved at Stripe to a product on another ladder, or removed, and the organization page or the subscriptions list is read before the next reconcile | The card and the list read the subscription as Active, nothing else | No badge on either: both apply the reconcile's choice over the current items, so neither marks a ladder the subscription no longer pays for | D4 |
| F30 | The operator's subscriptions list shows a displaced subscription | Its row reads Active, its period and its sync state, nothing else | The same, with the Not delivering badge in the Status cell and the title the card gives an account of one subscription, from three reads per page | D4 |
| F31 | A read of the list's displacements, current items or their ladders fails | The list reads none | The rows render; "Failed to load deliveries." above the table; no row carries the badge; L4 | D4 |
| F32 | An operator opens Issue grant on an organization whose subscription holds a tier on a ladder a product of the form is a tier of | The product is marked ⚠, and the warning says the subscription keeps billing "until it is changed in the billing view", whose routes are all reads | The same mark; the warning says the subscription keeps billing and delivers nothing on that ladder until the grant ends | D4 (d) |
| F33 | A displaced subscription is `paused` at Stripe | The card and the list read it as Paused, nothing else | No badge: Stripe creates no invoice for it, so "Billing continues" would be false; the badge appears once the subscription is active again | D4 |
| F34 | An operator opens Issue grant on an organization whose `paused` subscription holds a suspended position on a ladder a product of the form is a tier of | The product is marked ⚠; the warning says the subscription keeps billing "until it is changed in the billing view" | The same mark and O5; "keeps billing" is false while the subscription is paused, accepted until #269 rewords the warning; once it resumes it bills and delivers nothing on that ladder, and the badge appears | D4 (d) |
## Locks and transactions
@@ -277,7 +316,7 @@ No compatibility code: the rows the defect wrote are fixed by hand after a read-
Suspended branch and ended branch: as `reconcile-takes-pool-lock` leaves them.
Why the order is safe is that change's (its design D2, `docs/database-locks.md`); the two reads this change adds take no lock. The D10 follow-up must lock by pool only: a revocation that held the pool and then took a subscription's lock would wait in a cycle with a reconcile that holds the subscription's lock and waits for the pool.
Why the order is safe is that change's (its design D2, `docs/database-locks.md`); the two reads this change adds take no lock. The D10 follow-up (#270) must lock by pool only: a revocation that held the pool and then took a subscription's lock would wait in a cycle with a reconcile that holds the subscription's lock and waits for the pool.
This change's edit to `docs/database-locks.md` is one sentence in "Subscription reconcile", Active: the live read is followed by the shapes read and the displacement read, under the pool row.
@@ -289,15 +328,17 @@ Every sentence a person reads that this change adds. The UI copy rules apply (`A
| # | Where | Text |
|---|---|---|
| O1 | Billing card, Subscriptions row, one muted line under the row's value line, when the account holds one subscription and it is displaced (D4) | `Delivers nothing on <ladders>, which <holder>`, for example `Delivers nothing on Hosting, which a grant holds` |
| O2 | The same line when the account holds several subscriptions, `<k>` of them displaced | `<k> delivers nothing on <ladders>, which <holder>` for one, `<k> deliver nothing on <ladders>, which <holder>` for more, for example `1 delivers nothing on Hosting, which another subscription holds` |
| O3 | The same line's place, when a read for it failed | `Failed to load deliveries.` |
| O1 | The badge's label, in the warning tone, state key `not_delivering` (D4): on the Billing card's Subscriptions row, directly after the row's state badge, when at least one subscription of the account is displaced; on a displaced subscription's row in the subscriptions list, in the Status cell after the state badge and the Canceling badge | `Not delivering` |
| O2 | The badge's title. For one subscription (the card of an account with one subscription, and every row of the list); and for the card of an account with `<n>` subscriptions, `<k>` of them displaced | `Billing continues while <holder> <ladders>.` for one, for example `Billing continues while a grant holds Hosting.`; `Billing continues on <k> of <n> subscriptions while <holder> <ladders>.` for several, for example `Billing continues on 1 of 2 subscriptions while another subscription holds Hosting.` |
| O3 | The card, under the Subscriptions row's value line, in the danger style with `role="alert"`, when a read for the badge failed; no badge renders (unchanged from section 6) | `Failed to load deliveries.` |
| O4 | The subscriptions list, one line above the table, in the danger style with `role="alert"`, when a read for the badges failed; no row carries the badge | `Failed to load deliveries.` |
| O5 | Issue grant, the warning under the form's lead when a product the select offers is marked ⚠ (`issueGrantSupersedeWarning`), replacing `Products marked ⚠ would supersede a tier currently held by a subscription; the subscription keeps billing until it is changed in the billing view.` | `Products marked ⚠ would supersede a tier a subscription pays for; the subscription keeps billing and delivers nothing on that ladder until the grant ends.` |
The O1 and O2 lines carry no terminal period, as the page's other muted values carry none ("No subscriptions", "No invoices issued"); O3 is an error message and keeps the period, in the form of the members section's `Failed to load members.`
The first draft's O1 and O2, the muted line `Delivers nothing on <ladders>, which <holder>` and its counted form, are removed (D4, replaced on review). O1 is in the map's title case, as `Past due` and `Canceling` are. The O2 titles are sentences and end with a period, as the Canceling badge's title does ("Cancels at the period end and will not renew."); the part also renders the title after the label as visually hidden text, so a screen reader reads "Not delivering, Billing continues while a grant holds Hosting." O3 and O4 are error messages and keep the period, in the form of the members section's `Failed to load members.`; O4 repeats O3 so that one failure reads one way on both pages. O5 keeps the ⚠ marker the select's labels carry.
`<ladders>` is the distinct displaced ladders' display names (distinct by ladder id) sorted by `strings.Compare` in `displacementNote`, whatever order the query returns them in, joined `A`, `A and B`, `A, B and C`. `<holder>` is `a grant holds`, `another subscription holds` or `another plan holds` when one provision holds every ladder the line names, by that provision's source (`another plan` for a purchase, which no console path writes), and `other plans hold` when several provisions hold them.
`<ladders>` is the distinct displaced ladders' display names (distinct by ladder id) sorted by `strings.Compare` in `displacementTitle`, whatever order the query returns them in, joined `A`, `A and B`, `A, B and C` (`joinWithAnd`). `<holder>` is `a grant holds`, `another subscription holds` or `another plan holds` when one provision holds every ladder the title names, by that provision's source (`another plan` for a purchase, which no console path writes), and `other plans hold` when several provisions hold them. `<k>` is the number of the account's subscriptions that are displaced and whose status `subscriptionMayBeDisplaced` admits; `<n>` is the number of the account's subscriptions, the row's count.
Reused unchanged: the card's state badges, its count and period clause, and the issue form's warning (`issueGrantSupersedeWarning`).
Reused unchanged: the card's state badges, its count and period clause, and the list's state, Canceling and sync badges.
### Log lines
@@ -306,6 +347,7 @@ Reused unchanged: the card's state badges, its count and period clause, and the
| L1 | `convergeConferrals`, a product D2 leaves alone (Info, D13) | `reconcile: product not conferred; another plan holds its ladder`, attributes `subscription_id`, `product_id`, `plan_ladder_id`, `holder_provision_id` |
| L2 | `convergeConferrals`, a product D5 leaves out (Warn, D13) | `reconcile: product not conferred; a higher tier of the subscription holds its ladder`, attributes `subscription_id`, `product_id`, `plan_ladder_id`, `outranked_by_product_id` |
| L3 | `loadOrgBillingSummary`, a failed items, shapes or displacement read (Warn) | `org enrollment: subscription displacement read failed`, attributes `org_id`, `billing_account_id`, `error` |
| L4 | `loadSubscriptionsData`, a failed items, shapes or displacement read for the page (Warn) | `billing subscriptions: subscription displacement read failed`, attribute `error` |
## Tests
@@ -317,6 +359,7 @@ Each test claim is pinned by a break (`docs/testing.md`, "Prove a test with deli
- **The rule** (section 4): nine entitlements tests of the function (`TestSubscriptionDisplacements_*`), from an operator grant to the latest episode after a comeback; fulfillment tests through the Stripe mock for F1, F2, F4 to F7, F11, F13, F14, F16, F18, F19, F21 and F23 (the tasks' T1 to T9 and T11 to T13); `TestDisplacedProducts`; the trace goldens flip.
- **The choice** (section 5): `TestChooseTiers` over hand-built shapes, each row in two input orders; `TestReconcile_TwoTiersOfOneLadderConferTheHigher` through the Stripe mock with the items in each order.
- **The page** (section 6): `TestDisplacementNote` compares every Copy row's whole sentence; `TestDisplacedOnPage` pins that a displacement on a ladder no current item names reads no line; `TestSubscriptionMayBeDisplaced`; the organization page's read-count row gains a displaced subscription; `TestOrgEnrollmentGolden` gains the state `grant-over-subscription`.
- **The badge, the list and the warning** (section 8): `TestDisplacementTitle` (`TestDisplacementNote` rewritten) compares every O2 form's whole sentence; `TestDisplacementTitleFor` and `TestSubscriptionTitlesFor` pin the status filter and that a list row is worded alone; `TestDisplacementBadge` pins the label, the tone and the title on both view models; `TestStatusBadgeMapIsComplete` names `not_delivering`; `TestSubscriptionsListMarksADisplacedSubscription` drives the list over the World and compares its title with the card's; `TestSubscriptionsListDisplacementReadFailures` fails each of the list's three reads; the read-count row `billing subscriptions` grows displaced subscriptions; `TestOrgEnrollmentGolden`'s `grant-over-subscription` reads the badge; `TestOrgEnrollmentIssueGrantPanel_Stacked` reads O5.
Breaks no test sees, with the reason:
- The function's `succ.subscription_id IS DISTINCT FROM p_subscription_id`: the subscription's own successor on a ladder is always its latest episode there (it activates when the ended one ends, and an episode activated in the same transaction sorts after by its id), so the clause never decides a row the latest-episode rule leaves.
@@ -326,6 +369,8 @@ Breaks no test sees, with the reason:
- The comeback case (no row once the subscription holds the ladder again): the latest-episode rule and the holder's exclusion of the subscription each decide it alone.
- The match of an old transition row without an episode id: the constraint refuses such a row, so no test can write one.
- A holder made optional (the holder join turned into an outer join) is not used as the break of "displacement alone": a row without a holder fails the query's scan before any assertion runs, so the break counts an ended position as a holder instead.
- The list's item read without `si.ended_at IS NULL`: no fixture ends a displaced subscription's item and gives it another, and the card's item read has carried the same filter unpinned since section 6.
- The list's pool join without `rp.status = 'active'`: every fixture's pool is active, and an organization holds one default pool whatever its status.
## What is unverified
@@ -349,10 +394,10 @@ The previous binary runs against the migrated schema: it reads neither new objec
## Risks / Trade-offs
- **A paying member gets nothing on the ladder while the grant holds it.** That is what the operator chose and the issue form says; the organization page now says it too. The member's own view is out of scope (D4).
- **A displaced subscription delivers again only at its next reconcile** after the holder ends, often its renewal, and the floor's default fills the gap (F11, F12; D10).
- **A member whose subscription a grant displaced cannot cancel it in the console while the grant holds the ladder**, now for the grant's whole life rather than one period; only an operator acting in Stripe's Dashboard can.
- **A legacy grant from a default change can hold a ladder against a subscription** (F25), reachable only through the F11 gap, and indefinitely once it does: the grandfather move writes a grant with no end date, so the subscription bills and delivers nothing until an operator revokes it; the Billing card's line says a grant holds the ladder.
- **A paying member gets nothing on the ladder while the grant holds it.** That is what the operator chose and the issue form now says; the organization page and the subscriptions list mark the subscription Not delivering. The member's own view is out of scope (D4, #269).
- **A displaced subscription delivers again only at its next reconcile** after the holder ends, often its renewal, and the floor's default fills the gap (F11, F12; D10, #270).
- **A member whose subscription a grant displaced cannot cancel it in the console while the grant holds the ladder**, now for the grant's whole life rather than one period; only an operator acting in Stripe's Dashboard can (#269).
- **A legacy grant from a default change can hold a ladder against a subscription** (F25), reachable only through the F11 gap, and indefinitely once it does: the grandfather move writes a grant with no end date, so the subscription bills and delivers nothing until an operator revokes it; the Not delivering badge's title says a grant holds the ladder.
- **Two extra reads per active reconcile**, under the pool row, which other acts on the pool now wait behind for slightly longer.
- **The organization page runs three more statements** for the account, none per subscription.
- **The organization page runs three more statements** for the account, none per subscription, **and the subscriptions list three more** for the page, none per row.
- **Every active-branch trace golden gains two lines** (the two reads in section 4); the sections name the directories, and no other golden may change.
@@ -17,7 +17,8 @@ The same root cause has two more forms. A subscription that carries two items fo
- **The item pass writes in ascending product id order**, never in Stripe's item order or Go's map order.
- **Ride-along: the live-provisions query orders by `provision_id` after `created_at`.** `GetLivePoolProvisionsBySubscriptionID` ordered by `created_at` alone; provisions conferred in one transaction share it, so `endSurplusConferrals` ended surplus provisions in no stated order (`docs/go-conventions.md`, "Output order"). No behaviour a caller can name changes.
- **One statement of a supersession pair.** A view, `core.supersession_pairs`, pairs an `end` row with the typed row of the conferral that caused it, which `core.grant_replacements` already does for grants alone. `core.grant_replacements` is re-expressed over it with its rows unchanged, and the displacement function reads it.
- **The organization page says so.** The Billing card's Subscriptions row carries one muted line when a subscription of the account delivers nothing while another plan holds its ladder, read through the reconcile's own choice over the subscription's current items, for example "Delivers nothing on Hosting, which a grant holds" (design, Copy table). A failed read shows the section's error in the line's place, never an absent line.
- **The organization page and the subscriptions list say so.** A subscription that bills and delivers nothing while another plan holds its ladder carries a `Not delivering` badge, in the warning tone, beside its state badge: on the Billing card's Subscriptions row and on that subscription's row in the operator's subscriptions list. The badge's title names the ladders and the holder, for example "Billing continues while a grant holds Hosting." (design, Copy table). Both read the reconcile's own choice over the subscription's current items, the list in three statements per page. A failed read shows the section's error, "Failed to load deliveries.", never a subscription that reads as delivering. The first draft's muted line under the row was replaced by the badge on review (design D4).
- **The issue form's warning says what the grant does to the subscription.** It reads "Products marked ⚠ would supersede a tier a subscription pays for; the subscription keeps billing and delivers nothing on that ladder until the grant ends." and no longer sends the operator to "the billing view", whose routes are all reads.
## Order of landing
@@ -27,15 +28,13 @@ The same root cause has two more forms. A subscription that carries two items fo
## Out of scope
Each is a candidate issue or a note for the maintainer, filed only on his go.
Each is filed as an issue of its own, or is a note. The issue form's sentence and the subscriptions list's missing fact, listed here in the first draft, are fixed in this change (What Changes; tasks, section 8).
- **What the member sees of a displaced subscription.** The Products page shows the grant's tier with no price line, while invoices keep arriving; a fact there needs a decision on whether a member can cancel such a subscription in the console, which today no plan move can do, because every move resolves the subscription through the ladder's position (`resolveLadderSubscription`, `internal/fulfillment/plan_change.go:770`). The comment on `resolveCurrentPlanBilling` (`internal/server/member_plans.go`) that a grant-held tier "genuinely" has no billing is false while a displaced subscription bills. Waits on a product decision; #159 covers a grant above the paid tier, not at or below it.
- **The issue form's sentence.** `issueGrantSupersedeWarning` (`internal/server/operator_enrollment_forms.go:132`) sends the operator to "the billing view", whose routes are all reads (`internal/server/testdata/routes.golden:61-73`). Rewording it to name what changes a subscription waits on the same product decision, and touches the requirement `suspended-plans-stay-held` modifies.
- **A displaced subscription delivers again only at its next reconcile.** Between the grant's end and that reconcile, the floor's default or nothing fills the ladder. The more correct moment is the act that ends the holder (revocation, expiry, a subscription's end, an extension's lineage), before the floor, the way `ResumeReplacedGrantsTx` resumes a marked grant: confer the displaced, still-billing subscription from its mirrored items, and suspend it when it is past due. It is a second resumption mechanism with its own conditions and tests, so it is a candidate issue of its own. It must lock by pool only: an ending act that held the pool and then took a subscription's lock would wait in a cycle with a reconcile that holds the subscription's lock and waits for the pool. It would also let a marked grant that a displaced subscription had replaced resume when that subscription ends, which `grant-resumption` today says it does not.
- **A bundle's other rungs stay vacant.** A grant on one ladder of a multi-ladder product ends the subscription's rungs on every ladder (whole-bundle supersession); after this change the other rungs stay vacant until the grant ends. Candidate issue, beside #88's Checkout analogue.
- **Checkout above the grant while the first subscription bills.** After a grant displaces a subscription, the Products page offers Checkout above the grant, so a second concurrent subscription is sellable, which `docs/models/payments-billing.md` invariant 12 says cannot happen. Candidate issue, or a note on #159.
- **The sweeper's term-boundary switch reads the first item.** `firePlanSwitch` and `scheduledChangeMappings` (`internal/fulfillment/sweep.go`) act on the subscription's first current item, the wrong one on a two-item subscription. Latent, since a term-boundary switch needs a commitment no console path writes. A note on #233.
- **The operator's subscriptions list** (`/operator/billing/subscriptions`) shows no displacement; it lists subscriptions across organizations and reads no pool. Note only.
- **What the member sees of a displaced subscription** (#269). The Products page shows the grant's tier with no price line, while invoices keep arriving; a fact there needs a decision on whether a member can cancel such a subscription in the console, which today no plan move can do, because every move resolves the subscription through the ladder's position (`resolveLadderSubscription`, `internal/fulfillment/plan_change.go:770`). The comment on `resolveCurrentPlanBilling` (`internal/server/member_plans.go`) that a grant-held tier "genuinely" has no billing is false while a displaced subscription bills. Decided on #269 as a change of its own: while a grant holds the ladder, the subscription's collection is paused at Stripe, and the member sees the paused plan and can cancel it; #159 covers a grant above the paid tier, not at or below it.
- **A displaced subscription delivers again only at its next reconcile** (#270). Between the grant's end and that reconcile, the floor's default or nothing fills the ladder. The more correct moment is the act that ends the holder (revocation, expiry, a subscription's end, an extension's lineage), before the floor, the way `ResumeReplacedGrantsTx` resumes a marked grant: confer the displaced, still-billing subscription from its mirrored items, and suspend it when it is past due. It is a second resumption mechanism with its own conditions and tests, so it is an issue of its own. It must lock by pool only: an ending act that held the pool and then took a subscription's lock would wait in a cycle with a reconcile that holds the subscription's lock and waits for the pool. It would also let a marked grant that a displaced subscription had replaced resume when that subscription ends, which `grant-resumption` today says it does not.
- **A bundle's other rungs stay vacant** (#271). A grant on one ladder of a multi-ladder product ends the subscription's rungs on every ladder (whole-bundle supersession); after this change the other rungs stay vacant until the grant ends. It sits beside #88's Checkout analogue.
- **Checkout above the grant while the first subscription bills** (#277). After a grant displaces a subscription, the Products page offers Checkout above the grant, so a second concurrent subscription is sellable, which `docs/models/payments-billing.md` invariant 12 says cannot happen.
- **The sweeper's term-boundary switch reads the first item.** `firePlanSwitch` and `scheduledChangeMappings` (`internal/fulfillment/sweep.go`) act on the subscription's first current item, the wrong one on a two-item subscription. Latent, since a term-boundary switch needs a commitment no console path writes. Posted as a comment on #233.
- **The plan-ending paths' lock order** (#180) and **the member's plan change racing a grant** (#202) are unchanged. After this change the reconcile at the end of #202's race no longer ends the grant, but the member's change still reaches Stripe for a subscription that no longer holds the ladder, which stays #202's.
- **Rows the defect already wrote.** No compatibility code: the landing reads a count of grants a subscription took back on each deployment, as the reader role, and each hit is fixed by hand.
@@ -47,19 +46,20 @@ None.
### Modified Capabilities
- `stripe-subscription-management`: modified "Subscription reconcile computes a genuine diff, grouped by product" (displacement, the higher of two tiers, the fixed order), "Suspension and resume propagate via sync_source_status" (the recovery's item pass, the Billing card in place of an unnamed warning) and "Scheduled moves converge with Stripe" (a downgrade that lands while a grant holds the ladder).
- `stripe-subscription-management`: modified "Subscription reconcile computes a genuine diff, grouped by product" (displacement, its readers, the higher of two tiers, the fixed order), "Suspension and resume propagate via sync_source_status" (the recovery's item pass, the Billing card and the subscriptions list in place of an unnamed warning) and "Scheduled moves converge with Stripe" (a downgrade that lands while a grant holds the ladder).
- `grant-resumption`: modified "The source that replaced a grant is identified from the transition audit" (the pair stated once in `core.supersession_pairs`; the two-conferrals scenario reworded, because the reconcile no longer confers two tiers of one ladder).
- `plan-enrollment-administration`: added "The billing card says when a subscription delivers nothing while another plan holds its ladder".
- `plan-enrollment-administration`: added "The billing card says when a subscription delivers nothing while another plan holds its ladder" (the Not delivering badge and its title); modified "Advisory warning when a grant would supersede a subscription-sourced position" (the warning's sentence).
- `operator-billing-views`: added "The subscriptions list marks a subscription that delivers nothing while another plan holds its ladder".
## Impact
- **Code:** `internal/fulfillment/reconcile.go` (the fixed order, the displacement skip, the choice of tiers, two log lines, the pure rule exported for the page), `internal/entitlements/queries` (`ListSubscriptionDisplacements`, `ListAccountSubscriptionDisplacements`, `ListConferralShapes`), `internal/billing/queries` (`ListCurrentSubscriptionItemsByBillingAccountID`), `internal/server/operator_enrollment.go` and `operator_enrollment.html` (the Billing card line), the trace harness `internal/fulfillment/trace_db_test.go`, `test/pin-timestamps.sql` (base tables only, because the new view exposes `effective_at` and cannot be updated).
- **Code:** `internal/fulfillment/reconcile.go` (the fixed order, the displacement skip, the choice of tiers, two log lines, the pure rule exported for the page), `internal/entitlements/queries` (`ListSubscriptionDisplacements`, `ListAccountSubscriptionDisplacements`, `ListSubscriptionDisplacementsBySubscriptionIDs`, `ListConferralShapes`), `internal/billing/queries` (`ListCurrentSubscriptionItemsByBillingAccountID`, `ListCurrentSubscriptionItemsBySubscriptionIDs`), `internal/server/operator_enrollment.go` and `operator_enrollment.html` (the Billing card's badge), `internal/server/operator_billing.go` and `operator_subscriptions.html` (the list's badge), `internal/server/anatomy.go` (the `not_delivering` badge state), `internal/server/operator_enrollment_forms.go` (the issue form's warning), the trace harness `internal/fulfillment/trace_db_test.go`, `test/pin-timestamps.sql` (base tables only, because the new view exposes `effective_at` and cannot be updated).
- **Schema:** two core migrations, `00033_supersession_pairs.sql` (a view, and `core.grant_replacements` re-expressed over it) and `00034_subscription_displacements.sql` (a function). No column, no data, no grant change beyond the new objects' own.
- **Stripe calls:** none added, none removed.
- **Routes:** none.
- **Behaviour a deployment sees:** an operator's grant over a paid tier keeps the ladder until it ends, while the subscription keeps billing; the organization page says which ladder the subscription delivers nothing on. A subscription with two tiers of one ladder stops alternating, after one last switch to the higher tier at its first reconcile after the deploy.
- **Behaviour a deployment sees:** an operator's grant over a paid tier keeps the ladder until it ends, while the subscription keeps billing; the organization page's Billing card and the subscription's row in the subscriptions list carry a Not delivering badge whose title names the ladder and what holds it, and the issue form's warning says so before the grant is issued. A subscription with two tiers of one ladder stops alternating, after one last switch to the higher tier at its first reconcile after the deploy.
- **Deploy:** re-run `scripts/db-reader-role.sql` after the migrations apply, so the reader role reads the new view (`docs/database-management.md`, "Running the script").
- **Tests:** fulfillment tests through the Stripe mock for each state the design's failure table names, an entitlements test of the function, unit tests of the order and the choice, the trace goldens, the organization page golden and its read-count row, and one test of a reconcile waiting for the pool row.
- **Tests:** fulfillment tests through the Stripe mock for each state the design's failure table names, an entitlements test of the function, unit tests of the order and the choice, the trace goldens, the organization page golden and its read-count row, the subscriptions list's tests and its read-count row, and one test of a reconcile waiting for the pool row.
- **Docs:** `docs/database-locks.md`, `docs/models/plan-ladders-transitions.md`, `docs/models/payments-billing.md`, `docs/plan-management.md`, `docs/stripe.md`.
## Related issues
@@ -69,3 +69,4 @@ None.
- **#148** (`suspended-plans-stay-held`, in flight) edits the organization page this change's last section edits; that section waits for #148's organization-page commit. The two changes modify different requirements and archive in any order.
- **#218** asks `core.confer` for a stated order of its own loops; this change orders the reconcile's calls, not the function's.
- **#179** would make transitions point at their act; then only `core.supersession_pairs`' body changes.
- **#269** (what the member sees of a displaced subscription, and cancelling it), **#270** (delivering again in the act that ends the holder), **#271** (a bundle's vacated rungs) and **#277** (Checkout above the grant while the first subscription bills) were filed from "Out of scope".
@@ -0,0 +1,37 @@
## ADDED Requirements
### Requirement: The subscriptions list marks a subscription that delivers nothing while another plan holds its ladder
In the operator's subscriptions list (`/operator/billing/subscriptions`), the row of a subscription whose status is `active`, `trialing`, `past_due` or `unpaid` and that is displaced on a ladder another plan holds SHALL carry the "Not delivering" badge, in the warning tone, through the shared status-badge part, in its Status cell after the state badge and, when it renders, the Canceling badge. The row SHALL be decided by the rule the organization composite's billing card applies (`plan-enrollment-administration`, "The billing card says when a subscription delivers nothing while another plan holds its ladder"): `core.subscription_displacements` for the subscription and its organization's active default pool, applied through the reconcile's choice over the subscription's current items, so the list and the card never disagree about a subscription. The badge's title SHALL be "Billing continues while {holder} {ladders}.", worded as the card words it for an account of one subscription, because a row is one subscription. A canceled, `paused` or `incomplete` subscription, a subscription no other plan displaced, and a ladder held only by the organization type's default SHALL produce no badge.
The list SHALL read the displacements, the current items and their ladders once for the page, never once per row. A failed read of any of them SHALL be logged, SHALL leave every row without the badge, and SHALL render "Failed to load deliveries." once, above the table, in the danger style, while the rows the page read still render with their state, period and sync status.
#### Scenario: A grant over a subscription marks its row
- **WHEN** an operator views the subscriptions list, and an operator's grant superseded a listed subscription's position on ladder Hosting and still delivers
- **THEN** that row's Status cell reads its state badge and the "Not delivering" badge titled "Billing continues while a grant holds Hosting."
#### Scenario: A delivering subscription has no badge
- **WHEN** a listed subscription delivers on every ladder its current items name
- **THEN** its row carries no "Not delivering" badge
#### Scenario: A canceled subscription has no badge
- **WHEN** a listed subscription that an operator's grant superseded has been canceled
- **THEN** its row carries no "Not delivering" badge
#### Scenario: The list and the card agree
- **WHEN** an organization has one subscription, displaced by an operator's grant
- **THEN** the badge's title on its row in the subscriptions list equals the title of the badge on the organization's billing card
#### Scenario: The page reads once
- **WHEN** two more displaced subscriptions are added to the subscriptions a page of the list shows
- **THEN** the page runs no more statements than before
#### Scenario: A failed read says so above the table
- **WHEN** the read of the displacements for the page fails
- **THEN** the list renders its rows without the badge and "Failed to load deliveries." once above the table
@@ -2,44 +2,67 @@
### Requirement: The billing card says when a subscription delivers nothing while another plan holds its ladder
On the organization composite, the billing card's Subscriptions row SHALL carry one muted line under its value line when at least one subscription of the billing account whose status is `active`, `trialing`, `past_due`, `unpaid` or `paused` is displaced on a ladder that another plan holds, as `core.subscription_displacements` states it for the subscription and the organization's default pool, for a product among the subscription's current items, through the same choice the reconcile makes (`stripe-subscription-management`, "Subscription reconcile computes a genuine diff, grouped by product"). The card SHALL NOT restate the rule, and a displacement on a ladder that no current item of the subscription names SHALL produce no line. The line SHALL name the ladders, by their display names, and the kind of plan that holds them, and SHALL name no product, as the rest of the card names none:
On the organization composite, the billing card's Subscriptions row SHALL carry a "Not delivering" badge, in the warning tone, through the shared status-badge part, directly after the row's state badge and before the count, when at least one subscription of the billing account whose status is `active`, `trialing`, `past_due` or `unpaid` is displaced on a ladder that another plan holds, as `core.subscription_displacements` states it for the subscription and the organization's default pool, for a product among the subscription's current items, through the same choice the reconcile makes (`stripe-subscription-management`, "Subscription reconcile computes a genuine diff, grouped by product"). The card SHALL NOT restate the rule, and a displacement on a ladder that no current item of the subscription names SHALL produce no badge. The badge's title SHALL name the ladders, by their display names, and the kind of plan that holds them, and SHALL name no product, as the rest of the card names none:
- for an account with one subscription: "Delivers nothing on {ladders}, which {holder}";
- for an account with several subscriptions, of which k are displaced: "{k} delivers nothing on {ladders}, which {holder}" when k is 1, and "{k} deliver nothing on {ladders}, which {holder}" otherwise.
- for an account with one subscription: "Billing continues while {holder} {ladders}.";
- for an account with n subscriptions, n greater than 1 and the row's count, of which k are displaced: "Billing continues on {k} of {n} subscriptions while {holder} {ladders}."
{ladders} SHALL be the distinct displaced ladders' names sorted by `strings.Compare`, joined as "A", "A and B", or "A, B and C". {holder} SHALL be "a grant holds", "another subscription holds" or "another plan holds" when one provision holds every ladder the line names, by that provision's source (a grant, a subscription other than the displaced one, or any other source), and "other plans hold" when several provisions hold them. A subscription that is canceled or `incomplete`, a subscription no other plan displaced, and a ladder held only by the organization type's default SHALL produce no line. For a product of several ladders the line SHALL name the displaced ladder the function picked, not the product's other ladders. A failed read of the displacements, the subscription's items or their ladders SHALL be logged and SHALL show the section's error in the line's place, never an absent line. The card SHALL change nothing at Stripe and SHALL offer no control for the condition.
{ladders} SHALL be the distinct displaced ladders' names sorted by `strings.Compare`, joined as "A", "A and B", or "A, B and C". {holder} SHALL be "a grant holds", "another subscription holds" or "another plan holds" when one provision holds every ladder the title names, by that provision's source (a grant, a subscription other than the displaced one, or any other source), and "other plans hold" when several provisions hold them. A subscription that is canceled, `paused` or `incomplete`, a subscription no other plan displaced, and a ladder held only by the organization type's default SHALL produce no badge. For a product of several ladders the title SHALL name the displaced ladder the function picked, not the product's other ladders. The row SHALL carry no other text for the condition. A failed read of the displacements, the subscription's items or their ladders SHALL be logged, SHALL show "Failed to load deliveries." under the row's value line in the danger style, and SHALL render no badge, so the row never reads as if every subscription delivered. The card SHALL change nothing at Stripe and SHALL offer no control for the condition.
#### Scenario: A grant over the only subscription
- **WHEN** an operator views the organization page of an organization whose one subscription, on ladder Hosting, was superseded by an operator's grant that still delivers
- **THEN** the Subscriptions row reads its state badge, "1 subscription" as a link and its period clause, and under them the muted line "Delivers nothing on Hosting, which a grant holds"
- **THEN** the Subscriptions row reads its state badge, the "Not delivering" badge titled "Billing continues while a grant holds Hosting.", "1 subscription" as a link and its period clause
#### Scenario: Another subscription holds the ladder
- **WHEN** the account holds two subscriptions on ladder Hosting, and the later one superseded the earlier one's position
- **THEN** the line reads "1 delivers nothing on Hosting, which another subscription holds"
- **THEN** the badge's title reads "Billing continues on 1 of 2 subscriptions while another subscription holds Hosting."
#### Scenario: The default does not count
- **WHEN** the grant that superseded the organization's subscription was revoked and the floor conferred the organization type's default on the ladder
- **THEN** the Subscriptions row carries no such line
- **THEN** the Subscriptions row carries no "Not delivering" badge
#### Scenario: A subscription that delivers has no line
#### Scenario: A subscription that delivers has no badge
- **WHEN** every subscription of the account delivers on its ladders
- **THEN** the Subscriptions row carries no such line
- **THEN** the Subscriptions row carries no "Not delivering" badge
#### Scenario: A canceled subscription has no line
#### Scenario: A canceled subscription has no badge
- **WHEN** the account's only subscription, superseded by an operator's grant, has been canceled
- **THEN** the Subscriptions row carries no such line
- **THEN** the Subscriptions row carries no "Not delivering" badge
#### Scenario: A subscription whose item left the ladder has no line
#### Scenario: A subscription whose item left the ladder has no badge
- **WHEN** an operator's grant superseded the organization's subscription on ladder Hosting, and the subscription's only current item now names a product of another ladder
- **THEN** the Subscriptions row carries no such line
- **THEN** the Subscriptions row carries no "Not delivering" badge
#### Scenario: A failed read shows the section's error
- **WHEN** the read of the displacements fails
- **THEN** the Subscriptions row shows "Failed to load deliveries." in the line's place, and not an absent line
- **THEN** the Subscriptions row shows "Failed to load deliveries." under its value line and no "Not delivering" badge
## MODIFIED Requirements
### Requirement: Advisory warning when a grant would supersede a subscription-sourced position
When an operator's grant submission would cause `confer` to supersede a position currently held by a subscription-sourced provision, active or suspended, the UI SHALL display an advisory warning, before or alongside the confirmation of the submission, stating that the underlying subscription continues to bill, and delivers nothing on that ladder until the grant ends, even though its pool position is about to be superseded: "Products marked ⚠ would supersede a tier a subscription pays for; the subscription keeps billing and delivers nothing on that ladder until the grant ends." (`stripe-subscription-management`, "Subscription reconcile computes a genuine diff, grouped by product", states when the subscription delivers again). The warning SHALL NOT send the operator to another view to change the subscription, because the operator's billing views only read. A suspended position counts because `confer` supersedes a suspended incumbent as it supersedes an active one, and the subscription keeps being retried and billed, or bills again once a paused one resumes. This is a visibility measure only: the system SHALL NOT block the submission, and SHALL NOT modify or cancel the subscription as a side effect of the grant (Doc 41 §11.2 names this as an open cross-layer question the operator UI makes visible, not one this capability resolves).
#### Scenario: Operator is warned before superseding a subscription-held position
- **WHEN** an operator submits a grant for a product that would supersede a ladder position currently delivered by a subscription-sourced provision
- **THEN** the UI SHALL display an advisory warning that the subscription will continue billing, and deliver nothing on that ladder until the grant ends, after the position is superseded
- **AND** the submission SHALL proceed to create the grant and invoke `confer` if the operator confirms
#### Scenario: Operator is warned about a suspended subscription's position
- **WHEN** an operator opens Issue grant for an organization whose subscription on a ladder is `past_due`, so its position there is suspended
- **THEN** the form SHALL mark the products that are tiers of that ladder as superseding a subscription and SHALL show the advisory warning
#### Scenario: The warning names no view to change the subscription
- **WHEN** an operator opens Issue grant and the Product select offers a product marked ⚠
- **THEN** the warning under the form's lead reads "Products marked ⚠ would supersede a tier a subscription pays for; the subscription keeps billing and delivers nothing on that ladder until the grant ends."
- **AND** it names no billing view
@@ -4,7 +4,7 @@
Subscription reconcile SHALL compute the desired item state from the subscription's current items grouped by product — an item's product resolved via its price, with two items sharing one product collapsed into a single desired entry at the summed quantity — and SHALL compare that desired state against the subscription's live pool provisions, matched on `product_id` (the live-uniqueness grain is `(subscription_id, product_id)`, backed by a partial unique index scoped to non-ended provisions). Reconcile SHALL NOT tear down and recreate the subscription's provisions wholesale on each run; it SHALL act only on the computed delta between desired and live state. A product present in the desired state but absent from the live provisions (missing) SHALL be conferred via `entitlements.confer`, sourced by the subscription, at the item's quantity and period window, unless another plan took one of its ladders from the subscription and still holds it.
The subscription is **displaced** on a ladder when its latest episode there (its latest `pool_provision_ladders` row on that ladder in the pool, by activation time and then id) has ended in a supersession whose successor is not one of the subscription's own provisions, that is, when the episode's `end` row and the successor's typed row form a row of `core.supersession_pairs` (`grant-resumption`, "The source that replaced a grant is identified from the transition audit"). **Another plan holds** the ladder while an `active` or `suspended` position on it belongs to a provision that is neither the subscription's nor default-sourced. One function, `core.subscription_displacements(subscription_id, pool_id)`, SHALL state both rules, returning each ladder on which the subscription is displaced and another plan holds it, with the subscription's product that was displaced there and the holder's provision and source; reconcile and the organization page SHALL read the rule through it and SHALL NOT restate it. Reconcile SHALL NOT confer a missing product any ladder of whose current shape is such a ladder: it SHALL leave the whole product alone, because a conferral cannot place part of a product's shape, log it, and change nothing at Stripe, so the subscription keeps billing. A default-sourced position never holds a ladder against a subscription, so once no other plan holds the ladder, the subscription's next reconcile confers the product again. A subscription never displaced on a ladder, a new purchase over a grant among them, confers as before and supersedes what holds the ladder.
The subscription is **displaced** on a ladder when its latest episode there (its latest `pool_provision_ladders` row on that ladder in the pool, by activation time and then id) has ended in a supersession whose successor is not one of the subscription's own provisions, that is, when the episode's `end` row and the successor's typed row form a row of `core.supersession_pairs` (`grant-resumption`, "The source that replaced a grant is identified from the transition audit"). **Another plan holds** the ladder while an `active` or `suspended` position on it belongs to a provision that is neither the subscription's nor default-sourced. One function, `core.subscription_displacements(subscription_id, pool_id)`, SHALL state both rules, returning each ladder on which the subscription is displaced and another plan holds it, with the subscription's product that was displaced there and the holder's provision and source; reconcile, the organization page and the operator's subscriptions list SHALL read the rule through it and SHALL NOT restate it. Reconcile SHALL NOT confer a missing product any ladder of whose current shape is such a ladder: it SHALL leave the whole product alone, because a conferral cannot place part of a product's shape, log it, and change nothing at Stripe, so the subscription keeps billing. A default-sourced position never holds a ladder against a subscription, so once no other plan holds the ladder, the subscription's next reconcile confers the product again. A subscription never displaced on a ladder, a new purchase over a grant among them, confers as before and supersedes what holds the ladder.
When two or more desired products that are not left alone this way have current shapes that share a ladder, reconcile SHALL keep the one with the highest rank on any of its ladders, ties broken by the lower `product_id`: it walks the products in that order and keeps a product unless a product kept before it holds one of its ladders. A product it leaves out is logged with the product that outranked it, is not conferred, and is not ended. Reconcile SHALL confer and update the products it keeps in ascending `product_id` order, never in the order of Stripe's items or of a map.
@@ -118,7 +118,7 @@ Reconcile SHALL make its displacement read and its live read under the pool's ro
### Requirement: Suspension and resume propagate via sync_source_status
When a subscription transitions to a suspending status (`past_due`) or resumes (returns to `active`), reconcile SHALL propagate the change to the subscription's live pool provisions by calling `entitlements.sync_source_status`, rather than by ending or recreating the provision. Suspension SHALL NEVER permanently end a provision: `sync_source_status` SHALL only ever move a provision's status between `suspended` and `active` and SHALL NOT write `status = 'ended'`. If a provision is superseded by a different source while suspended (per `entitlements.confer`'s supersession, which considers `suspended` incumbents equally with `active` ones), that provision SHALL remain ended, and a subsequent resume of its original source SHALL NOT resurrect it — `sync_source_status` SHALL find no non-ended target for that source and SHALL return the empty set. A recovery reaches the item pass, which follows the resume in the same transaction, and the item pass SHALL NOT confer the subscription's product again while another plan holds the ladder (see "Subscription reconcile computes a genuine diff, grouped by product"). The resulting condition, the original subscription billing for a position it no longer holds, SHALL stay in place until no other plan holds the ladder. It SHALL be surfaced through the operator-facing audit trail and the organization page's Billing card (`plan-enrollment-administration`, "The billing card says when a subscription delivers nothing while another plan holds its ladder"), and reconcile SHALL NOT attempt to stop or refund the subscription's billing.
When a subscription transitions to a suspending status (`past_due`) or resumes (returns to `active`), reconcile SHALL propagate the change to the subscription's live pool provisions by calling `entitlements.sync_source_status`, rather than by ending or recreating the provision. Suspension SHALL NEVER permanently end a provision: `sync_source_status` SHALL only ever move a provision's status between `suspended` and `active` and SHALL NOT write `status = 'ended'`. If a provision is superseded by a different source while suspended (per `entitlements.confer`'s supersession, which considers `suspended` incumbents equally with `active` ones), that provision SHALL remain ended, and a subsequent resume of its original source SHALL NOT resurrect it — `sync_source_status` SHALL find no non-ended target for that source and SHALL return the empty set. A recovery reaches the item pass, which follows the resume in the same transaction, and the item pass SHALL NOT confer the subscription's product again while another plan holds the ladder (see "Subscription reconcile computes a genuine diff, grouped by product"). The resulting condition, the original subscription billing for a position it no longer holds, SHALL stay in place until no other plan holds the ladder. It SHALL be surfaced through the operator-facing audit trail, the organization page's Billing card (`plan-enrollment-administration`, "The billing card says when a subscription delivers nothing while another plan holds its ladder") and the operator's subscriptions list (`operator-billing-views`, "The subscriptions list marks a subscription that delivers nothing while another plan holds its ladder"), and reconcile SHALL NOT attempt to stop or refund the subscription's billing.
#### Scenario: past_due suspends without ending
@@ -137,7 +137,7 @@ When a subscription transitions to a suspending status (`past_due`) or resumes (
- **WHEN** a subscription's provision was ended by a competing `confer` call while the subscription was suspended, and the subscription later resumes
- **THEN** `entitlements.sync_source_status` SHALL find no non-ended provision for that source and SHALL return the empty set
- **AND** the subscription SHALL NOT regain the superseded position, neither through the resume nor through the item pass that follows it, while another plan holds the ladder
- **AND** the system SHALL surface the resulting billing/entitlement mismatch via the audit trail and the Billing card's line, rather than resolving it automatically
- **AND** the system SHALL surface the resulting billing/entitlement mismatch via the audit trail and the Not delivering badge on the Billing card and in the subscriptions list, rather than resolving it automatically
#### Scenario: A recovery through the reconcile leaves the grant in place
@@ -1,6 +1,6 @@
Tests follow `docs/testing.md`: database tests open the database through `dbtest`, build their rows through the test kit or the package's fixtures, filter to the rows their own fixture made, and pass twice in a row without a reset; tests meet the same complexity limit as production code, so a table runs its rows through a named function. A Stripe test drives `stripetest.MockBackend` with the API state Stripe would return, never a hand-built event payload, and runs without `t.Parallel()`. A test that asserts that no Stripe call was made compares the mock's recorded calls before and after the act. A test of a lock holds one transaction open and waits for the block with `dbtest.AwaitLockWait`, never a sleep. A test of an order feeds its input in more than one order. A page's test compares the whole sentence with its entry in the design's Copy table.
This change starts from the tree that `reconcile-takes-pool-lock` (#193) leaves, which has landed: the reconcile takes the pool row in both branches and the traces already hold its lock statement. Sections 1 to 6 are one commit each, and their items are that commit's parts; section 7 is not a commit. Each section passes `make test` alone with the test stack up, which runs `make lint` first (`filler-copy`, `em-dash-copy`, `screen-coverage`, the notebook-citations check and `deadcode` among its checks) and then every package one at a time (`-p 1`). A query edit is followed by `make sqlc-generate`. A migration is applied by `test/reset-test-db.sh` before the section's tests run (`make test` runs it). No golden, route table or test result changes except where a section's items name it; any other change is a stop. A golden is rewritten with `-update` only where an item says so, and its diff is read before the commit. Section 4 adds a test that runs a reconcile on a goroutine, so it runs its named test with `go test -race -p 1 -count=1` after `test/reset-test-db.sh`. Section 6 changes a template, so it runs `make screens` after its tests pass. Section 6 starts only after `suspended-plans-stay-held`'s organization-page section is committed, because both edit `internal/server/operator_enrollment.go`, `operator_enrollment.html` and the `org_enrollment` goldens. The Copy table is approved by the maintainer before section 6 starts; a string that differs from its approved entry is a stop. A script's usage is learned from its header or source, never by running it.
This change starts from the tree that `reconcile-takes-pool-lock` (#193) leaves, which has landed: the reconcile takes the pool row in both branches and the traces already hold its lock statement. Sections 1 to 6 and 8 are one commit each, and their items are that commit's parts; section 7 is not a commit. Section 8 was added on review after items 7.1 and 7.2 were recorded; its commit follows them and lands before items 7.3 to 7.6. Each section passes `make test` alone with the test stack up, which runs `make lint` first (`filler-copy`, `em-dash-copy`, `screen-coverage`, the notebook-citations check and `deadcode` among its checks) and then every package one at a time (`-p 1`). A query edit is followed by `make sqlc-generate`. A migration is applied by `test/reset-test-db.sh` before the section's tests run (`make test` runs it). No golden, route table or test result changes except where a section's items name it; any other change is a stop. A golden is rewritten with `-update` only where an item says so, and its diff is read before the commit. Section 4 adds a test that runs a reconcile on a goroutine, so it runs its named test with `go test -race -p 1 -count=1` after `test/reset-test-db.sh`. Sections 6 and 8 change templates, so each runs `make screens` after its tests pass. Section 6 starts only after `suspended-plans-stay-held`'s organization-page section is committed, because both edit `internal/server/operator_enrollment.go`, `operator_enrollment.html` and the `org_enrollment` goldens. The Copy table is approved by the maintainer before section 6 starts, and its rows O1, O2, O4 and O5 as section 8 rewrote them before section 8 starts; a string that differs from its approved entry is a stop. A script's usage is learned from its header or source, never by running it.
**Breaks.** Each section's list is written to a file outside the repository and run with `go run ./scripts/mutation-check -list <file> -side new` on the section's commit, 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; the tests are new or rewritten in the section, so there is no old side. A break of Go code or of a generated sqlc file is a test record (`file`, `old`, `new`, `pkg`, `run_new`, `want_new`); a break of a sqlc query edits the generated `.sql.go` file beside the querier, whose constant the test runs, not the `.sql` file. A break of a migration's SQL is a command record, because the shared test database runs the view or the function as it was migrated: `cmd` is `(cd test && ./reset-test-db.sh) && go test -p 1 -count=1 -run '<pattern>' <pkg>`, `exit` 1, and the list carries `"prepare": "cd test && ./reset-test-db.sh"`, so each record starts from the restored migration and the last `prepare` restores it. Every record's `want_new` is a text the failing test prints only when it fails, never a test or subtest name; for a golden it is `<path> differs from what the code produced`, which `internal/testkit/golden/golden.go:42` prints only on a mismatch, with the path as the test passes it. 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. A break that no test of its own section sees is named with the reason, and the commit message says so. The commit message lists every break with its result.
@@ -153,7 +153,7 @@ This change starts from the tree that `reconcile-takes-pool-lock` (#193) leaves,
## 6. The organization page says which ladder a subscription delivers nothing on
- [x] 6.1 `internal/server/operator_enrollment.go` (design D4; the Copy table, rows O1 to O3, approved before this section starts):
- `subscriptionMayBeDisplaced(status string) bool`, true for `active`, `trialing`, `past_due`, `unpaid` and `paused`.
- `subscriptionMayBeDisplaced(status string) bool`, true for `active`, `trialing`, `past_due`, `unpaid` and `paused`. Superseded by item 8.2: paused is no longer admitted.
- `displacedSubscription` (each displaced ladder's id and display name, and each holder's provision id and source) and `displacementNote(subscriptions int, displaced []displacedSubscription) string`, reading no row: `""` when nothing is displaced; O1 when `subscriptions` is 1; O2 otherwise, singular for one displaced subscription and plural for more; `<ladders>` is the distinct displaced ladders, by ladder id: `displacementNote` walks its input in order, appends each ladder whose id it has not seen to a slice, then sorts the slice by `strings.Compare` on the names, whatever order the query returned them in; `<holder>` is as the Copy table fills it, counting distinct holder provisions.
- `displacedOnPage(products []string, rungs map[string][]fulfillment.Rung, displacements []fulfillment.Displacement) displacedSubscription`, reading no row: it calls `fulfillment.DisplacedProducts` with the subscription's current products as the missing set (a product with a live provision is on no displaced ladder, design D2) and names the ladder of each displacement it picks, so a displacement whose ladder no current product names produces nothing. For a product of several ladders it names the one displaced ladder the function picked (design F21).
- Queries, then `make sqlc-generate`: `ListAccountSubscriptionDisplacements :many` in `internal/entitlements/queries/pool_provision_transitions.sql`, `FROM core.subscriptions s CROSS JOIN LATERAL core.subscription_displacements(s.subscription_id, sqlc.arg(pool_id)::uuid) AS d(plan_ladder_id, displaced_product_id, displaced_at, holder_provision_id, holder_source)` joined to `core.plan_ladders pl` for the ladder name, `WHERE s.billing_account_id = sqlc.arg(billing_account_id)::uuid`, with the columns of item 4.2's query and the subscription id, its comment saying the status filter stays in Go; `ListCurrentSubscriptionItemsByBillingAccountID :many` in `internal/billing/queries/subscription_items.sql`: `si.subscription_id`, `si.product_id` from `core.subscription_items si JOIN core.subscriptions s USING (subscription_id)` where `s.billing_account_id = sqlc.arg(billing_account_id)::uuid AND si.ended_at IS NULL`, ordered `si.subscription_id, si.product_id` (the rows `mirrorItems` writes), in one statement; and the item 4.2 query `ListConferralShapes` for the products those items name. The ladder names come from the displacement query's join, in one statement.
@@ -164,7 +164,7 @@ This change starts from the tree that `reconcile-takes-pool-lock` (#193) leaves,
- `TestOrgEnrollmentReadFailures` (`internal/server/operator_enrollment_read_failures_db_test.go`) gains three rows, each failing one read of the displacement line through a method added to the existing wrappers: `ListCurrentSubscriptionItemsByBillingAccountID` on `failingEnrollmentBillingQ` (the row's querier `billing`); `ListAccountSubscriptionDisplacements` and `ListConferralShapes` on `failingEnrollmentEntitlementsQ` (the row's querier `entitlements`). Each asserts `BillingSummary.DisplacementError == "Failed to load deliveries."` and that the render holds the O3 text. `checkEnrollmentBaseline`'s error case gains `|| d.BillingSummary.DisplacementError != ""`, so a displacement read that failed in the baseline fails the baseline. A failure reads `DisplacementError after a failed %s read = %q, want "Failed to load deliveries."`.
- `TestDisplacedOnPage` (beside it), rows through a named function: `a displacement on a ladder a current product names` (one ladder); `the item moved off the ladder` (a displacement on Hosting, the current product names only another ladder: nothing, so the card reads no line); `the item removed` (no current product: nothing); `a bundle displaced on one ladder` (the held ladder only). A failure reads `displacedOnPage(%s) = %+v, want %+v`.
- `TestDisplacementLineFor` (beside it; `displacementLineFor` is the reads-nothing half of `displacementLine`), rows through a named function: `a canceled subscription has no line` (a canceled subscription, a current item of a tier on a ladder, a displacement row on it held by a grant: `""`); `an active subscription has the line` (the same rows, active: `Delivers nothing on Hosting, which a grant holds`); `one active of two` (an active and a canceled subscription, each with such a row: `1 delivers nothing on Hosting, which a grant holds`). A failure reads `displacementLineFor(%s) = %q, want %q`.
- `TestSubscriptionMayBeDisplaced`, one row per status `subscriptionStatusFacets` lists (`internal/server/operator_billing.go`): true for `active`, `trialing`, `past_due`, `unpaid` and `paused`, false for `incomplete`, `incomplete_expired` and `canceled`. A failure reads `subscriptionMayBeDisplaced(%q) = %v, want %v`.
- `TestSubscriptionMayBeDisplaced`, one row per status `subscriptionStatusFacets` lists (`internal/server/operator_billing.go`): true for `active`, `trialing`, `past_due`, `unpaid` and `paused`, false for `incomplete`, `incomplete_expired` and `canceled`. A failure reads `subscriptionMayBeDisplaced(%q) = %v, want %v`. Superseded by item 8.2: paused is no longer admitted.
- `TestOrgEnrollmentGolden` (`internal/server/operator_enrollment_golden_db_test.go`): the state `grant-over-subscription`, an organization of the plain type subscribed to the fixture's existing Small2 on ladder Box (`w.Subscribe`, period end noon on Jan 15, 2099) and issued a grant of Small2 over it (`f.grant("Small2", w.Issue(o, small2, world.IssueOpts{}))`), which takes the subscription's position as a `transfer`. It adds no product, because every state's Issue grant catalog lists all the fixture's products and a new one would change every golden. Its goldens are written with `-update`: `.html` reads the Subscriptions row with the line `Delivers nothing on Box, which a grant holds` and the Pools panel with the grant's delivery; `.json` carries the same `DisplacementNote`. Every other `org_enrollment` JSON golden gains `"DisplacementNote": ""` and `"DisplacementError": ""` in its `BillingSummary` and no other change; no other HTML golden changes. The commit message names the files.
- The read-count row of the organization page (`internal/server/operator_read_count_db_test.go`, `TestOperatorPagesReadAFixedNumberOfTimes`) gains a subscription a grant displaced in its `grow` (a second subscription on its own ladder and a grant of its tier over it), so the page's statement count is pinned at no statement per displaced subscription; its `perRow` stays 0.
- [x] 6.4 Docs: `docs/plan-management.md`, "Issue Grant" (item 4.8's bullet): the organization page's Billing card then reads "Delivers nothing on {ladder}, which a grant holds" under the subscription; `docs/stripe.md`'s bullet **A grant over a paid plan.** gains the same sentence; `docs/models/plan-ladders-transitions.md` (the `core.subscription_displacements` row and invariant 20) and the doc comments of `RungsByProduct` and `DisplacedProducts` in `internal/fulfillment/reconcile.go` gain back the clause that the organization page reads the rule too.
@@ -231,7 +231,7 @@ This change starts from the tree that `reconcile-takes-pool-lock` (#193) leaves,
```
It reads direct pairs only, so a grant extended between taking the ladder and being taken back is not listed (design, What is unverified).
- [ ] 7.4 After the push, post on #212 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, with the commits of sections 1 to 6 filled in:
- [ ] 7.4 After the push, post on #212 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, with the commits of sections 1 to 6 and 8 filled in:
> A grant that replaced a subscription's tier now keeps the ladder until it ends or is revoked. (A ladder is a sequence of tiers such as Plus, Pro and Max; an organization holds one tier on each ladder at a time, from a subscription or from a grant.)
>
@@ -241,11 +241,99 @@ This change starts from the tree that `reconcile-takes-pool-lock` (#193) leaves,
>
> The case in the comment above is fixed as well: a subscription that carries two tiers of one ladder gets the higher-ranked one, and the other is logged as a warning, so the tier no longer alternates. The reconcile now confers in a fixed order, by product id. Two subscriptions of one organization on one ladder no longer alternate either: the later one keeps the ladder.
>
> On the organization page, the Billing card's Subscriptions row says when a subscription delivers nothing, on which ladder, and whether a grant or another subscription holds it.
> On the organization page, the Billing card's Subscriptions row now shows a "Not delivering" badge beside the subscription's state when a subscription keeps billing while another plan holds one of its ladders. The text shown on the badge when the pointer rests on it names the ladder and what holds it (a grant, another subscription, or another plan), for example "Billing continues while a grant holds Hosting." The subscription's row in the operator's subscriptions list carries the same badge, and the list and the card apply the same check. The Issue grant form's warning now says that the subscription keeps billing and delivers nothing on that ladder until the grant ends, instead of pointing at the billing views, which cannot change a subscription.
>
> Not changed here: the member's Products page says nothing new about a subscription that delivers nothing, and the issue form still points at the billing view; both wait on a decision about what a member can do with such a subscription. A subscription delivers again at its next reconcile after the grant ends, not at the moment the grant ends.
> Not changed here: the subscription keeps billing while the grant holds the ladder, and the member's Products page says nothing new about it; pausing its collection, and what the member then sees, is #269. A subscription delivers again at its next reconcile after the grant ends, not at the moment the grant ends (#270).
>
> Commits: …
- [ ] 7.5 Handed to the maintainer, none filed without his go: the candidate issues in the proposal's "Out of scope" (what the member sees of a displaced subscription and whether the member can cancel it; the issue form's sentence; delivering again in the act that ends the holder, with the constraint that it locks by pool only; a bundle's vacated rungs; Checkout above the grant while the first subscription bills, or a note on #159), the note on #233 (the sweeper's term-boundary switch reads the first item), and the result of item 7.3. The candidate issue on what the member sees of a displaced subscription is member-harming: a member whose subscription a grant displaced cannot cancel it in the console while the grant holds the ladder.
- [ ] 7.6 The archive waits for the maintainer's go, and only after `reconcile-takes-pool-lock` is archived; the deltas carry every spec change, and no Purpose needs an edit a delta cannot make. Nothing in this change writes to a production host: item 7.3 is a read under the reader role, and any fix it leads to is a separate act on the maintainer's go.
- [ ] 7.5 Handed to the maintainer: the result of item 7.3. The proposal's "Out of scope" items are filed (#269, #270, #271, #277), the sweeper's first-item read is posted on #233, and the issue form's sentence is fixed in section 8, so nothing else waits on his go here.
- [ ] 7.6 The archive waits for the maintainer's go; `reconcile-takes-pool-lock` is archived. The deltas carry every spec change, and no Purpose needs an edit a delta cannot make. Nothing in this change writes to a production host: item 7.3 is a read under the reader role, and any fix it leads to is a separate act on the maintainer's go.
- [x] 7.7 Section 8's landing record, before items 7.3 to 7.6: with the test stack up, `make test` passes on section 8's commit (it runs `make lint` first), `openspec validate displaced-subscription-waits --strict` passes, and section 8's break list reads CAUGHT or names its unseen breaks with the reason.
## 8. A displaced subscription shows a badge, on the organization page and in the subscriptions list
One commit, after items 7.1 and 7.2 (design D4, "Replaced on review"; the Copy table, rows O1 to O5). The badge replaces the muted line section 6 wrote; O3 stays as section 6 wrote it. Item 8.2 also narrows item 6.1's statuses: paused is no longer admitted (design D4, F33).
- [x] 8.1 `internal/server/anatomy.go` (design D4):
- `badgeMap` (`:188-328`) gains `"not_delivering": {Label: "Not delivering", Tone: "warning"}` among the subscription states (`:189-194`), with a comment: a subscription that keeps billing while another plan holds one of its ladders (displaced-subscription-waits D4), its title saying which. `knownStates` (`:343-364`) gains `"not_delivering"`.
- `notDeliveringBadge(title string) Badge` returns `StatusBadge("not_delivering").WithTitle(title)`. `func (b BillingSummaryViewModel) DisplacementBadge() Badge`, beside `SubscriptionsBadge` (`:715-726`), and `func (s SubscriptionViewModel) DisplacementBadge() Badge`, beside `CancelingBadge` (`:1063-1067`), each return it with its view model's `DisplacementTitle`.
- [x] 8.2 `internal/server/operator_enrollment.go` (design D4; Copy rows O2 and O3):
- `BillingSummaryViewModel` (`:344-350`): `DisplacementNote` is renamed `DisplacementTitle`. Its comment says that it is the title of the Not delivering badge after the Subscriptions row's state badge, empty when no subscription is displaced, and that `DisplacementError` replaces the badge with the line under the value line when a read for it failed; it keeps the requirement's name.
- `displacementNote` (`:506-557`) becomes `displacementTitle(subscriptions int, displaced []displacedSubscription) string`, reading no row: `""` when nothing is displaced; `"Billing continues while " + displacementHolder(holders) + " " + joinWithAnd(names) + "."` when `subscriptions` is 1; otherwise `fmt.Sprintf("Billing continues on %d of %d subscriptions while %s %s.", count, subscriptions, displacementHolder(holders), joinWithAnd(names))`. The deduplication of ladders and holders and the sort are unchanged, and so is `displacementHolder` (`:559-572`). Its doc comment names Copy row O2.
- `subscriptionRef` (`ID`, `Status`) and `displacedByID(subs []subscriptionRef, products map[string][]string, rungs map[string][]fulfillment.Rung, displacements map[string][]fulfillment.Displacement) map[string]displacedSubscription`, reading no row: for each subscription whose status `subscriptionMayBeDisplaced` admits, `displacedOnPage` over its current products, the rungs and its displacements; a subscription with no displaced ladder has no entry. It is the loop of `displacementLineFor` (`:1820-1828`), moved, so the card and the list group by subscription in one function.
- `displacementsBySubscription(rows []entitlements.ListAccountSubscriptionDisplacementsRow) map[string][]fulfillment.Displacement`, reading no row: the one mapping of a displacement row's fields to `fulfillment.Displacement`, grouped by subscription. `displacementTitleFor` calls it with its rows; `subscriptionTitlesFor` converts each `ListSubscriptionDisplacementsBySubscriptionIDsRow` with `entitlements.ListAccountSubscriptionDisplacementsRow(r)`, which Go allows because item 8.4 gives the list's query the same seven columns in the same order (tags are ignored), so a column drift between the two queries fails to compile. `productsBySubscription` is the same for the two item row types (`SubscriptionID`, `ProductID`).
- `displacementLineFor` (`:1800-1830`) becomes `displacementTitleFor`, with the same parameters: it groups the items by subscription, converts the rows with `displacementsBySubscription`, calls `displacedByID`, and passes the entries, in the order of `subs`, to `displacementTitle(len(subs), ...)`. `displacementLine` (`:1774-1798`) becomes `accountDisplacementTitle`, its reads unchanged. `fillDisplacement` (`:1740-1761`) sets `DisplacementTitle`; its error branch is unchanged (L3, O3). The doc comments of `loadOrgBillingSummary` (`:1694-1696`), `fillDisplacement` (`:1740-1745`), `displacementLine` (`:1774-1776`) and `displacementLineFor` (`:1800-1803`) say "badge title" for "line", and nothing else in them changes.
- `subscriptionMayBeDisplaced` (`:460-466`) no longer admits `paused`: it is true for `active`, `trialing`, `past_due` and `unpaid` only, and its comment reads: `subscriptionMayBeDisplaced reports whether a subscription in the status still bills for its tiers, so another plan holding one of its ladders is a cost the badge names: active, trialing, past_due and unpaid. A paused subscription generates no invoice, so the badge's title would be false; the badge appears once it is active again (displaced-subscription-waits D4). A canceled or incomplete subscription pays for nothing.`
- [x] 8.3 `internal/embeds/templates/partials/operator_enrollment.html`:
- In the Subscriptions value line (`:86-92`), directly after `{{ template "statusBadge" .BillingSummary.SubscriptionsBadge }}`: `{{- if .BillingSummary.DisplacementTitle }}`, then `{{ template "statusBadge" .BillingSummary.DisplacementBadge }}`, then `{{- end }}`, each on its own line, so that the badge renders on its own line before the count link and no other golden changes.
- The `{{- else if .BillingSummary.DisplacementNote }}` branch and its `<div class="text-muted small">` (`:95-96`) are deleted; the `DisplacementError` branch (`:93-94`) and the `{{- end }}` after it stay.
- The comment above the rows (`:56-64`) says that the Subscriptions value line carries the Not delivering badge after its state badge when a subscription delivers nothing (DisplacementTitle), and that one danger line under it says the read for it failed (DisplacementError).
- [x] 8.4 Queries, then `make sqlc-generate` (design D4, "Reads, the list"):
- `ListCurrentSubscriptionItemsBySubscriptionIDs :many` in `internal/billing/queries/subscription_items.sql`, after `ListCurrentSubscriptionItemsByBillingAccountID` (`:33-42`): `si.subscription_id`, `si.product_id` from `core.subscription_items si` where `si.subscription_id = ANY(sqlc.arg(subscription_ids)::uuid[]) AND si.ended_at IS NULL`, ordered `si.subscription_id, si.product_id`. Its comment says that these are the current items of one page of the operator's subscriptions list, the rows `mirrorItems` writes, in one statement for the page (displaced-subscription-waits D4).
- `ListSubscriptionDisplacementsBySubscriptionIDs :many` in `internal/entitlements/queries/pool_provision_transitions.sql`, after `ListAccountSubscriptionDisplacements` (`:87-105`), with its seven columns and its order (`s.subscription_id, pl.name, d.plan_ladder_id::uuid, d.holder_provision_id::uuid`), over the `FROM` the design writes: `core.subscriptions s`, `core.accounts a` on the billing account, `core.resource_pools rp` on `rp.org_id = a.org_id AND rp.pool_type = 'default' AND rp.status = 'active'`, `CROSS JOIN LATERAL core.subscription_displacements(s.subscription_id, rp.pool_id)` with the column aliases, `core.plan_ladders pl` for the name, `WHERE s.subscription_id = ANY(sqlc.arg(subscription_ids)::uuid[])`. Its comment says that it applies `core.subscription_displacements` to each listed subscription with its organization's active default pool, the predicate `GetDefaultPoolByOrgID` states and the reconcile resolves its pool by, in one statement for the page, never one per row; that the aliases repeat the function's columns because sqlc cannot read a function's `RETURNS TABLE`; and that the status filter stays in Go.
- [x] 8.5 `internal/server/operator_billing.go` (design D4; Copy rows O2 and O4; log row L4):
- `SubscriptionViewModel` (`:428-446`) gains `DisplacementTitle string`, after `EnvState`, with a comment naming the requirement it renders (`operator-billing-views`, "The subscriptions list marks a subscription that delivers nothing while another plan holds its ladder"); `SubscriptionsData` (`:448-458`) gains `DisplacementError string`, after `Error`.
- `func (h *OperatorPartialsHandler) subscriptionDisplacementTitles(ctx context.Context, subs []billing.ListSubscriptionsPageRow) (map[string]string, error)`: with no subscription it reads nothing; otherwise it runs `ListCurrentSubscriptionItemsBySubscriptionIDs` with the page's subscription ids, `ListConferralShapes` with those items' products, and `ListSubscriptionDisplacementsBySubscriptionIDs` with the page's ids, once each, and returns `subscriptionTitlesFor` of them.
- `subscriptionTitlesFor(subs []billing.ListSubscriptionsPageRow, items []billing.ListCurrentSubscriptionItemsBySubscriptionIDsRow, shapes []entitlements.ListConferralShapesRow, rows []entitlements.ListSubscriptionDisplacementsBySubscriptionIDsRow) map[string]string`, reading no row: it groups the items by subscription (`productsBySubscription`), computes `fulfillment.RungsByProduct` over the shapes, runs `displacementsBySubscription` over the displacement rows, `displacedByID` over the page's subscriptions, and for each entry `displacementTitle(1, []displacedSubscription{d})`, because a row is one subscription.
- `loadSubscriptionsData` (`:494-590`) calls `subscriptionDisplacementTitles` after the mappings read (`:530-535`). A failed read logs L4 at Warn (`billing subscriptions: subscription displacement read failed`, attribute `error`), sets `DisplacementError` to O4 (`Failed to load deliveries.`), and leaves every row's `DisplacementTitle` empty; otherwise each row's `DisplacementTitle` is its subscription's entry, or empty. The loader stays under the complexity limit by splitting, not by a lint exception.
- [x] 8.6 `internal/embeds/templates/partials/operator_subscriptions.html`: in the Status cell (`:49-52`), after the Canceling marker's line, `{{ if .DisplacementTitle }}{{ template "statusBadge" .DisplacementBadge }}{{ end }}` on a line of its own; after `{{ template "listControls" .Nav }}` (`:27`) and before the table, `{{ if .DisplacementError }}<div class="small text-danger mb-2" role="alert">{{ .DisplacementError }}</div>{{ end }}`; the header comment (`:4-12`) names the Not delivering badge beside the status and the error line above the table.
- [x] 8.7 `internal/server/operator_enrollment_forms.go:130-132`: `issueGrantSupersedeWarning` reads O5, `Products marked ⚠ would supersede a tier a subscription pays for; the subscription keeps billing and delivers nothing on that ladder until the grant ends.`; its comment says that the sentence states what the grant does to the subscription and names no view, because the operator's billing views only read (design D4). The marker (`:200`) and the render (`:255-256`) are unchanged. The last sentence of the comment of `subscriptionHeldLadders` (`operator_enrollment.go:2039-2041`) becomes `A suspended position is held: core.confer supersedes it as it does an active one, and the subscription keeps billing in Stripe, or bills again once a paused one resumes.`, since the sentence it replaces, that the subscription stays in Stripe until it is changed in the billing view, is the one O5 removes for being false.
- [x] 8.8 Tests:
- `TestDisplacementTitle` (`internal/server/operator_enrollment_render_test.go:1294-1342`: `TestDisplacementNote`, its row type and its rows function renamed with it), every row's want rewritten to O2: `a grant holds the ladder` (`Billing continues while a grant holds Hosting.`); `another subscription holds the ladder` (`Billing continues while another subscription holds Hosting.`); `a purchase holds the ladder` (`Billing continues while another plan holds Hosting.`); `one grant holds two ladders` (`Billing continues while a grant holds Hosting and Support.`); `three ladders` (`Billing continues while a grant holds Docs, Hosting and Support.`); `two holders` (`Billing continues while other plans hold Hosting and Support.`); `one of several subscriptions`, 3 subscriptions (`Billing continues on 1 of 3 subscriptions while another subscription holds Hosting.`); `two of several subscriptions`, 3 subscriptions (`Billing continues on 2 of 3 subscriptions while other plans hold Hosting and Support.`); `ladders out of name order` (`Billing continues while a grant holds Hosting and Support.`); `two subscriptions displaced on one ladder`, 3 subscriptions (`Billing continues on 2 of 3 subscriptions while a grant holds Hosting.`); `nothing displaced` (`""`). A failure reads `displacementTitle(%s) = %q, want %q`.
- `TestDisplacementTitleFor` (`:1413-1461`: `TestDisplacementLineFor`, its row type and its rows renamed): `a canceled subscription has no badge` (`""`); `an active subscription has the badge` (`Billing continues while a grant holds Hosting.`); `one active of two` (`Billing continues on 1 of 2 subscriptions while a grant holds Hosting.`). A failure reads `displacementTitleFor(%s) = %q, want %q`.
- `TestSubscriptionTitlesFor` (beside it), rows through a named function: `a canceled subscription has no badge` (a canceled row, a current item of a tier on Hosting, a displacement row on Hosting held by a grant: no entry); `each displaced row has its own title` (two active rows, one displaced on Hosting by a grant and one on Docs by another subscription: `Billing continues while a grant holds Hosting.` and `Billing continues while another subscription holds Docs.`, never the account's form); `a delivering row has no badge` (an active row with a current item and no displacement row: no entry). The test compares with `maps.Equal`, so a `""` entry fails and a nil map and an empty one compare equal. A failure reads `subscriptionTitlesFor(%s) = %v, want %v`.
- `TestDisplacementBadge` (at the end of `operator_enrollment_render_test.go`): with the title `t` `Billing continues while a grant holds Hosting.`, `BillingSummaryViewModel{DisplacementTitle: t}.DisplacementBadge()` and `SubscriptionViewModel{DisplacementTitle: t}.DisplacementBadge()` each equal `Badge{Label: "Not delivering", Tone: "warning", Title: t}`. Failures read `BillingSummaryViewModel.DisplacementBadge() = %+v, want %+v` and `SubscriptionViewModel.DisplacementBadge() = %+v, want %+v`.
- `TestStatusBadgeMapIsComplete` (`internal/server/anatomy_test.go:15`) names `not_delivering` through `knownStates`; the test is not edited.
- `TestOrgEnrollmentReadFailures`: `wantDisplacementError` (`operator_enrollment_read_failures_db_test.go:395-410`) reads `b.DisplacementTitle`, its second failure text becomes `DisplacementTitle after a failed %s read = %q, want none`, and its doc comment says the error replaces the badge; the three displacement rows (`:383-385`) still want O3, and their names say `badge error` in place of `line error`. Every other reader of `DisplacementNote` in package `server` is renamed with the field, so the package compiles.
- `TestSubscriptionMayBeDisplaced` (section 6's, under item 6.3): the `paused` row becomes false, so the test wants true for `active`, `trialing`, `past_due` and `unpaid`, and false for `paused`, `incomplete`, `incomplete_expired` and `canceled`; its failure text is unchanged.
- `TestOrgEnrollmentProductsSection` (`operator_enrollment_render_test.go:702`), subtest `internal and supersession markers render on product options`: the substring it checks becomes `supersede a tier a subscription pays for`; its failure text `expected the supersession advisory when a ⚠ option is present` is unchanged.
- `TestSubscriptionsListMarksADisplacedSubscription` (a new `internal/server/operator_subscriptions_displacement_db_test.go`, package `server`, over the World, through the handler `catalogReadsHandler` builds, `operator_catalog_reads_db_test.go:71`): organization A, of an organization type of its own, subscribed to the tier Small of a ladder Box (`w.Subscribe`) and issued a grant of Small over it (`w.Issue`, a `transfer`), as the `grant-over-subscription` golden builds its state (`operator_enrollment_golden_db_test.go:218-222`); organization B subscribed to the tier of a ladder of its own, with no grant; each account made first with `w.BillingAccount(o, ...)`, which `w.Subscribe` then uses, and each subscription mapped (`w.MapSubscription`). `loadSubscriptionsData` with `?q=` the World's suffix: A's row has `DisplacementTitle` `Billing continues while a grant holds Box.` and B's row none; the list rendered through `operator_subscriptions.html` (`Render(t, OperatorSet(t), "operator_subscriptions.html", data)`, `harness_test.go:41`) holds, once, the badge as the part renders it: `<span class="badge text-bg-warning" title="Billing continues while a grant holds Box.">Not delivering<span class="visually-hidden">, Billing continues while a grant holds Box.</span></span>`; and `loadOrgEnrollmentData` for A reads the same title as `BillingSummary.DisplacementTitle`. Failures read `the displaced subscription's row title = %q, want "Billing continues while a grant holds Box."`, `the delivering subscription's row title = %q, want ""`, `the list does not render the Not delivering badge on the displaced row` and `the list's title %q differs from the organization page's %q`.
- `TestSubscriptionsListDisplacementReadFailures` (the same file, its rows run through a named function), over A's state, one row per read, each failing one read through a method added to the wrappers of `operator_enrollment_read_failures_db_test.go` (`:34-186`) and a handler from `failingEnrollmentConfig` (`:279`): `ListCurrentSubscriptionItemsBySubscriptionIDs` on `failingEnrollmentBillingQ` (querier `billing`); `ListConferralShapes`, which the wrapper already fails, and `ListSubscriptionDisplacementsBySubscriptionIDs` on `failingEnrollmentEntitlementsQ` (querier `entitlements`). Each row wants `DisplacementError` `Failed to load deliveries.`, A's row still listed with an empty `DisplacementTitle`, and the render holding `Failed to load deliveries.` exactly once (`strings.Count(page, "Failed to load deliveries.") == 1`), at an index below the index of `<table`. A baseline load with no failing read wants `DisplacementError` empty and A's title set, so a read that fails in the baseline fails the test. Failures read `DisplacementError after a failed %s read = %q, want "Failed to load deliveries."`, `the list after a failed %s read lists %d rows, want %d`, `the displaced row's title after a failed %s read = %q, want ""`, `the list after a failed %s read does not hold "Failed to load deliveries."` (a count of 0), `the list after a failed %s read holds "Failed to load deliveries." %d times, want 1`, `the list after a failed %s read puts "Failed to load deliveries." after the table` and `the baseline list failed its displacement read: %q`.
- The read-count row `billing subscriptions` (`internal/server/operator_read_count_db_test.go:67-69`, `TestOperatorPagesReadAFixedNumberOfTimes`): its grow adds a displaced subscription instead of a bare one, through a builder of its own, `displacedSubscriptionsPage`, which makes each row's organization and billing account as `newCountedAccounts` does (`:336-344`), maps the account, and adds a priced product on a ladder of its own, `w.Subscribe` on it (which uses that account), `w.MapSubscription`, and a grant of the product over it, as the organization page's row does (`:153-160`). Its target stays `/operator/billing/subscriptions?q=` and the World's suffix, and its `perRow` stays 0, so the list's statement count is pinned at no statement per displaced row.
- The doc comments of `TestDisplacementTitle` and `TestDisplacementTitleFor` (renamed) and of `TestDisplacedOnPage` say "badge" for "line" (`:1331-1333`, `:1378-1380`, `:1442-1443`); `TestDisplacementTitle`'s names Copy row O2.
- `TestOrgEnrollmentGolden` (item 8.9) and `TestOrgEnrollmentIssueGrantPanel_Stacked` (`operator_enrollment_render_test.go:509-554`): the markup the latter asserts (`:551`) carries O5 in place of the old sentence; its failure text is unchanged.
- [x] 8.9 Goldens, in `internal/server/testdata/org_enrollment/`, written with `-update` and each diff read before the commit:
- `grant-over-subscription.html`: the Subscriptions value line gains, on a line of its own between `<span class="badge text-bg-success">Active</span>` and the `1 subscription` link, `<span class="badge text-bg-warning" title="Billing continues while a grant holds Box.">Not delivering<span class="visually-hidden">, Billing continues while a grant holds Box.</span></span>`, and the line `<div class="text-muted small">Delivers nothing on Box, which a grant holds</div>` is gone. Nothing else changes.
- `grant-over-subscription.json`: `"DisplacementNote": "Delivers nothing on Box, which a grant holds"` becomes `"DisplacementTitle": "Billing continues while a grant holds Box."`, and nothing else changes.
- The nine other JSON goldens (`default-only`, `many-changes`, `no-org`, `no-pool`, `removed-tier`, `rich`, `rich-history-search`, `second-pool`, `suspended-subscription`): `"DisplacementNote": ""` becomes `"DisplacementTitle": ""`, and nothing else changes.
- `rich.html`, `rich-history-search.html` and `suspended-subscription.html`: the warning under Issue grant's lead reads O5 in place of the old sentence, and nothing else changes.
- No other golden changes, the route table included: no route is added. The subscriptions list has two goldens, in `internal/server/testdata/operator_billing/`, written by `TestBillingListsGolden` (`operator_billing_golden_db_test.go:170-186`): `subscriptions-default.json` and `subscriptions-all.json`. In each, every row gains `"DisplacementTitle": ""` after `"EnvState"`, and the data gains `"DisplacementError": ""` after `"Error"`; nothing else changes, because the fixture's subscriptions are `w.BareSubscription` rows with no item. The other six `operator_billing` goldens do not change. The commit message names the files.
- [x] 8.10 Docs (`CONTRIBUTING.md`, "Docs move with the code"):
- `docs/plan-management.md`, "Issue Grant": step 3 gains the sentence `A product whose grant would supersede a tier a subscription holds, active or suspended, is marked ⚠ in the **Product** list, and the form's warning says that the subscription keeps billing and delivers nothing on that ladder until the grant ends.` The system's bullet that item 6.4 edited ("Keeps the ladder for a grant that supersedes a tier a subscription pays for") ends `the subscription keeps billing and delivers nothing there while the grant lasts, and the organization page's Billing card and the subscription's row in the operator's subscriptions list carry a **Not delivering** badge whose title reads "Billing continues while a grant holds {ladder}."; the subscription delivers again at its next reconcile after the grant ends`. Another change's section had uncommitted edits in this file when this section was drafted: the edit is made on the tree those edits leave, and is placed by the section's heading and the bullet's text, never by a line number. The step-3 sentence rides along; the commit message names it.
- `docs/stripe.md`, "Plan changes", the bullet **A grant over a paid plan.**: the sentence `While the grant holds the ladder, the organization page's Billing card reads "Delivers nothing on {ladder}, which a grant holds" under the subscription.` becomes `While the grant holds the ladder, the organization page's Billing card and the subscription's row in the operator's subscriptions list carry a **Not delivering** badge, whose title reads "Billing continues while a grant holds {ladder}."`
- `docs/models/plan-ladders-transitions.md`: the `core.subscription_displacements` row ("The subscription reconcile and the organization page read it") and invariant 20 ("the reconcile and the organization page read it") name the operator's subscriptions list as a third reader.
- The doc comments of `RungsByProduct` and `DisplacedProducts` in `internal/fulfillment/reconcile.go` (`:676-679`, `:731-738`) name the subscriptions list beside the organization page, and the last clause of `DisplacedProducts`' comment says that neither the card nor the list marks a subscription Not delivering on a ladder the reconcile would confer.
- [x] 8.11 `make screens` after the tests pass: no changed screen is expected. The demo seed holds no subscription a grant displaced, so neither `operator-organization-detail` nor `operator-billing-subscriptions` shows the badge; and neither of the seed's two subscriptions (Bob's, and Carlos's canceling one) confers a position, so no product of the Issue grant form is marked ⚠ and O5 renders on no screen, the open state of `operator-organization-detail` included. A changed screen is a finding to report, never a baseline this change accepts.
- [x] 8.12 Hand capture on the Stripe sandbox, made by whoever lands the section: a grant issued over a sandbox subscription's tier, then the subscription reconciled by an edit at Stripe or a renewal, so the displacement is the reconcile's own. The organization page's Billing card and the subscription's row in the subscriptions list are each captured at 1280 wide and at the 390 mobile width (the viewport emulated at `390x844x2,mobile`, `innerWidth` checked at 390), under `test/screens/displaced-subscription-waits/` beside section 6's captures: `org-billing-badge-desktop.png`, `org-billing-badge-mobile.png`, `subscriptions-badge-desktop.png`, `subscriptions-badge-mobile.png`. The badge's title is a native tooltip, which a screenshot does not contain, so the capture records the badge in the screenshot and the badge's title read from the page by script (`evaluate_script`, `[...document.querySelectorAll('.badge')].find(b => b.firstChild.textContent === 'Not delivering').title`), recorded beside the captures in the commit message. At both widths the badge sits on the value line after the state badge, the list's Status cell holds both badges, and the page does not scroll sideways.
- [x] 8.13 Breaks (`pkg` `./internal/server` unless named):
- `"paused"` restored to the `case` of `subscriptionMayBeDisplaced`: `run_new` `^TestSubscriptionMayBeDisplaced$`, `want_new` `subscriptionMayBeDisplaced("paused") = true, want false`;
- `"not_delivering"` removed from `badgeMap`: `run_new` `^TestStatusBadgeMapIsComplete$`, `want_new` `state "not_delivering" is emitted but missing from badgeMap`;
- the badge's tone (`"not_delivering": {Label: "Not delivering", Tone: "warning"}` replaced by `"not_delivering": {Label: "Not delivering", Tone: "secondary"}`): `run_new` `^TestDisplacementBadge$`, `want_new` `BillingSummaryViewModel.DisplacementBadge() = `;
- the list's badge without its title (`notDeliveringBadge(s.DisplacementTitle)` in `SubscriptionViewModel.DisplacementBadge` replaced by `notDeliveringBadge("")`): `run_new` `^TestDisplacementBadge$`, `want_new` `SubscriptionViewModel.DisplacementBadge() = `;
- `DisplacementTitle` never set on the card (its assignment in `fillDisplacement` removed): `run_new` `^TestOrgEnrollmentGolden$/^grant-over-subscription$`, `want_new` `testdata/org_enrollment/grant-over-subscription.html differs from what the code produced`;
- the card's template without the badge (its `{{ template "statusBadge" .BillingSummary.DisplacementBadge }}` line removed): the same `run_new` and `want_new`;
- the account's form never chosen (`if subscriptions == 1 {` replaced by `if true {` in `displacementTitle`): `run_new` `^TestDisplacementTitle$`, `want_new` `displacementTitle(one of several subscriptions) =`;
- `<n>` read as the displaced count (`count, subscriptions,` replaced by `count, count,` in the `Sprintf`): `run_new` `^TestDisplacementTitle$`, `want_new` `displacementTitle(one of several subscriptions) =`;
- a subscription holder worded as a grant (`return "another subscription holds"` replaced by `return "a grant holds"` in `displacementHolder`): `run_new` `^TestDisplacementTitle$`, `want_new` `displacementTitle(another subscription holds the ladder) =`;
- several holders worded as one (`if len(holders) != 1 {` replaced by `if false {` in `displacementHolder`): `run_new` `^TestDisplacementTitle$`, `want_new` `displacementTitle(two holders) =`;
- the ladders joined with commas only (`+ " and " +` replaced by `+ ", " +` in `joinWithAnd`, `operator_partials.go:840-849`): `run_new` `^TestDisplacementTitle$`, `want_new` `displacementTitle(one grant holds two ladders) =`;
- the sort in `displacementTitle` removed (its `slices.SortFunc(ladders, ...)` call deleted): `run_new` `^TestDisplacementTitle$`, `want_new` `displacementTitle(ladders out of name order) =`;
- the ladders not deduplicated (`if !seenLadder[l.ID] {` replaced by `if true {`): `run_new` `^TestDisplacementTitle$`, `want_new` `displacementTitle(two subscriptions displaced on one ladder) =`;
- the holders not deduplicated (`if !seenHolder[h.ProvisionID] {` replaced by `if true {`): `run_new` `^TestDisplacementTitle$`, `want_new` `displacementTitle(one grant holds two ladders) =`;
- the status filter dropped (`if !subscriptionMayBeDisplaced(s.Status) {` replaced by `if false {` in `displacedByID`), one edit and two records: `run_new` `^TestDisplacementTitleFor$`, `want_new` `displacementTitleFor(a canceled subscription has no badge) =`; and `run_new` `^TestSubscriptionTitlesFor$`, `want_new` `subscriptionTitlesFor(a canceled subscription has no badge) =`;
- a list row worded in the account's form (`displacementTitle(1, []displacedSubscription{d})` replaced by `displacementTitle(len(subs), []displacedSubscription{d})` in `subscriptionTitlesFor`): `run_new` `^TestSubscriptionTitlesFor$`, `want_new` `subscriptionTitlesFor(each displaced row has its own title) =`; and a second record, `run_new` `^TestSubscriptionsListMarksADisplacedSubscription$`, `want_new` `differs from the organization page's`;
- the list's titles never set (the row's `DisplacementTitle` set to `strings.TrimPrefix(titles[id], titles[id])`, which is empty, in `loadSubscriptionsData`; removing the field's line would leave `titles` unused and not compile): `run_new` `^TestSubscriptionsListMarksADisplacedSubscription$`, `want_new` `the displaced subscription's row title = "", want`;
- the delivering row given the displaced row's title (`DisplacementTitle: titles[sub.SubscriptionID],` replaced by `DisplacementTitle: titles[subs[0].SubscriptionID] + titles[subs[len(subs)-1].SubscriptionID],` in `loadSubscriptionsData`; the page lists the displaced and the delivering subscription, so the sum is the displaced one's title and every row gets it): `run_new` `^TestSubscriptionsListMarksADisplacedSubscription$`, `want_new` `the delivering subscription's row title = "Billing`; not emulated;
- the list's template without the badge (its `{{ if .DisplacementTitle }}` line removed): `run_new` `^TestSubscriptionsListMarksADisplacedSubscription$`, `want_new` `the list does not render the Not delivering badge on the displaced row`;
- the list's displacements read with a pool that is not the default (in `internal/entitlements/pool_provision_transitions.sql.go`, the constant of `ListSubscriptionDisplacementsBySubscriptionIDs` with `rp.pool_type = 'default'` replaced by `rp.pool_type <> 'default'`): `run_new` `^TestSubscriptionsListMarksADisplacedSubscription$`, `want_new` `the displaced subscription's row title = "", want`;
- the list's reads moved into the row loop, an emulated one-text edit, called emulated in the commit as section 6's were: the call `h.subscriptionDisplacementTitles(ctx, subs)` replaced by an inline closure that calls `subscriptionDisplacementTitles` once per row with that row alone (a helper only the break calls would fail `deadcode`): `run_new` `^TestOperatorPagesReadAFixedNumberOfTimes$/^billing_subscriptions$`, `want_new` `two more rows cost`;
- the list's `DisplacementError` assignment and L4 removed: `run_new` `^TestSubscriptionsListDisplacementReadFailures$`, `want_new` `DisplacementError after a failed ListSubscriptionDisplacementsBySubscriptionIDs read =`;
- O4's template line removed (`{{ if .DisplacementError }}<div class="small text-danger mb-2" role="alert">{{ .DisplacementError }}</div>{{ end }}` deleted): `run_new` `^TestSubscriptionsListDisplacementReadFailures$`, `want_new` `does not hold "Failed to load deliveries."`;
- O4's line moved under the table (the same template line placed after `</table>`): the same `run_new`, `want_new` `puts "Failed to load deliveries." after the table`;
- the rows dropped on a failed read (in the error branch of `loadSubscriptionsData`, the assignment of `Failed to load deliveries.` to `DisplacementError` replaced by one that also sets the rows to nil, in the names the implementation uses): the same `run_new`, `want_new` `read lists 0 rows, want`;
- the badge's label (`Label: "Not delivering"` replaced by `Label: "Not Delivering"`): `run_new` `^TestDisplacementBadge$`, `want_new` `BillingSummaryViewModel.DisplacementBadge() = `;
- the card's badge without its title (`notDeliveringBadge(b.DisplacementTitle)` in `BillingSummaryViewModel.DisplacementBadge` replaced by `notDeliveringBadge("")`): the same `run_new` and `want_new`;
- a delivering row given an entry (`if d := displacedOnPage(...); len(d.Ladders) > 0 {` in `displacedByID` with `len(d.Ladders) > 0` replaced by `true`; `subscriptionTitlesFor` writes one entry per `displacedByID` entry): `run_new` `^TestSubscriptionTitlesFor$`, `want_new` `subscriptionTitlesFor(a delivering row has no badge) =`; not emulated;
- the issue form's old sentence restored in `issueGrantSupersedeWarning`: `run_new` `^TestOrgEnrollmentIssueGrantPanel_Stacked$`, `want_new` `the supersession warning does not follow the lead`;
- breaks no test sees, named in the commit with the reasons in the design's Tests: the list's item read without `si.ended_at IS NULL`, and its pool join without `rp.status = 'active'`.