Say on the organization page which ladder a subscription delivers nothing on

An operator could not see that a subscription bills while another plan
holds the ladder it pays for. The organization page's Billing card now
says it under the subscriptions row, from the same rule the reconcile
uses: the page reads the account's displacements, the subscriptions'
current items and their rungs once per account, and calls
fulfillment.RungsByProduct and DisplacedProducts over them, so a
subscription whose item moved off the ladder reads no line.

The line, for one subscription: "Delivers nothing on <ladders>, which a
grant holds" (or "which another subscription holds", "which another
plan holds", "which other plans hold"); for several: "<k> delivers
nothing on ..." or "<k> deliver nothing on ...". Ladders are distinct,
sorted by name, joined with joinWithAnd. A canceled or incomplete
subscription reads no line. A failed read of the displacements, the
items or their shapes logs and shows "Failed to load deliveries." in the
line's place, styled as the page's other errors, never an absent line.

Queries: ListAccountSubscriptionDisplacements (entitlements, one
statement per account with ladder names by join) and
ListCurrentSubscriptionItemsByBillingAccountID (billing). The page's
read count stays fixed per account (a read-count row grows displaced
subscriptions), and TestOrgEnrollmentReadFailures fails each new read.
The template uses trim markers, so every existing HTML golden is
unchanged; the new state grant-over-subscription shows the line, and
eight JSON goldens gain the two empty fields. plan-management.md,
stripe.md and plan-ladders-transitions.md now say the organization page
reads the rule.

Tests: TestDisplacementNote, TestDisplacedOnPage,
TestSubscriptionMayBeDisplaced, TestDisplacementLineFor (a canceled
subscription reads no line), the golden state, the read-count and
read-failure rows.

Breaks (scripts/mutation-check, 14 of 14 CAUGHT):
- DisplacementNote never set
- the template without the line
- a subscription holder worded as a grant
- several holders worded as one
- the ladders joined with commas only
- the plural form never chosen
- the sort removed
- the ladders not deduplicated
- the holders not deduplicated
- canceled admitted
- displacedOnPage without the current products
- the displacement read in a loop over the subscriptions
- the DisplacementError assignment and the L3 log line removed
- the status filter at the call site removed

make screens: no screen shows the line (the demo seed holds no
displaced subscription). Fourteen screens differ from the baseline, none
from this change: the host's time zone moved from Eastern to Central,
and the captures render pinned times in the host's zone, so every time
column reads an hour earlier; the Stripe integration page's wall-clock
"last read back" line and the five data-only differences seen on every
run tonight are the rest.

The suite on reset databases passes, 4686 tests, 15 skipped. make lint
passes; openspec validate --strict passes.
This commit is contained in:
2026-10-26 15:03:55 -05:00
parent e3f3ec56b9
commit 04862abe9c
28 changed files with 1465 additions and 19 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 reads 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 and the organization page 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 reads 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.
## Dimensions
+1 -1
View File
@@ -161,7 +161,7 @@ An operator can issue a grant to move a pool to a specific tier:
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 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 reads "Delivers nothing on {ladder}, which a grant holds" under the subscription; 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. A paused subscription shows its state and is refused every act.
- **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. 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 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.
- **Pay invoice.** The card of a plan whose payment is due, and the detail of an open invoice, link to Stripe's hosted invoice page. The console reads the page's URL from Stripe when the member opens the link and redirects there; it never stores or logs the URL, which is a bearer link, and it needs no key permission beyond the reads of subscriptions and invoices that the reconciles already make.
A restricted key needs permission for the calls these moves make, beyond those the console already uses: `POST /v1/subscription_schedules`, `POST /v1/subscription_schedules/{id}`, `POST /v1/subscription_schedules/{id}/release` and `POST /v1/invoices/{id}/void`. Stripe names the missing permission in the error a key without it receives.
+4
View File
@@ -267,6 +267,10 @@ type Querier interface {
// account id: it is time-ordered, which lists the later-written account first,
// so each lists on exactly one page.
ListBillingAccountsPage(ctx context.Context, arg ListBillingAccountsPageParams) ([]ListBillingAccountsPageRow, error)
// The current items of every subscription of the billing account, the rows
// mirrorItems writes, in one statement for the organization page's Billing
// card (displaced-subscription-waits D4).
ListCurrentSubscriptionItemsByBillingAccountID(ctx context.Context, billingAccountID string) ([]ListCurrentSubscriptionItemsByBillingAccountIDRow, 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.
@@ -29,3 +29,14 @@ RETURNING *;
UPDATE core.subscription_items
SET ended_at = NOW()
WHERE subscription_item_id = $1 AND ended_at IS NULL;
-- name: ListCurrentSubscriptionItemsByBillingAccountID :many
-- The current items of every subscription of the billing account, the rows
-- mirrorItems writes, in one statement for the organization page's Billing
-- card (displaced-subscription-waits D4).
SELECT 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
ORDER BY si.subscription_id, si.product_id;
@@ -124,6 +124,46 @@ func (q *Queries) GetSubscriptionItemsBySubscriptionID(ctx context.Context, subs
return items, nil
}
const listCurrentSubscriptionItemsByBillingAccountID = `-- name: ListCurrentSubscriptionItemsByBillingAccountID :many
SELECT si.subscription_id, si.product_id
FROM core.subscription_items si
JOIN core.subscriptions s USING (subscription_id)
WHERE s.billing_account_id = $1::uuid
AND si.ended_at IS NULL
ORDER BY si.subscription_id, si.product_id
`
type ListCurrentSubscriptionItemsByBillingAccountIDRow struct {
SubscriptionID string `json:"subscription_id"`
ProductID string `json:"product_id"`
}
// The current items of every subscription of the billing account, the rows
// mirrorItems writes, in one statement for the organization page's Billing
// card (displaced-subscription-waits D4).
func (q *Queries) ListCurrentSubscriptionItemsByBillingAccountID(ctx context.Context, billingAccountID string) ([]ListCurrentSubscriptionItemsByBillingAccountIDRow, error) {
rows, err := q.db.QueryContext(ctx, listCurrentSubscriptionItemsByBillingAccountID, billingAccountID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListCurrentSubscriptionItemsByBillingAccountIDRow{}
for rows.Next() {
var i ListCurrentSubscriptionItemsByBillingAccountIDRow
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,7 +58,10 @@
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. */}}
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). */}}
<div class="row g-3">
{{ if .BillingSummary.AccountAbnormal }}
<div class="col-12">
@@ -87,6 +90,11 @@
<small class="text-muted">· {{ .BillingSummary.SubscriptionNote }}</small>
{{ end }}
</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>
{{ end }}
@@ -16,6 +16,73 @@ import (
"github.com/google/uuid"
)
const listAccountSubscriptionDisplacements = `-- name: ListAccountSubscriptionDisplacements :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
CROSS JOIN LATERAL core.subscription_displacements(s.subscription_id, $1::uuid)
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.billing_account_id = $2::uuid
ORDER BY s.subscription_id, pl.name, d.plan_ladder_id::uuid, d.holder_provision_id::uuid
`
type ListAccountSubscriptionDisplacementsParams struct {
PoolID string `json:"pool_id"`
BillingAccountID string `json:"billing_account_id"`
}
type ListAccountSubscriptionDisplacementsRow 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 every subscription of the billing
// account on the pool (displaced-subscription-waits D4), for the organization
// page's Billing card: one statement for the account, never one per
// subscription. 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) ListAccountSubscriptionDisplacements(ctx context.Context, arg ListAccountSubscriptionDisplacementsParams) ([]ListAccountSubscriptionDisplacementsRow, error) {
rows, err := q.db.QueryContext(ctx, listAccountSubscriptionDisplacements, arg.PoolID, arg.BillingAccountID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAccountSubscriptionDisplacementsRow{}
for rows.Next() {
var i ListAccountSubscriptionDisplacementsRow
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 listRecentTransitions = `-- name: ListRecentTransitions :many
SELECT
t.transition_id,
+6
View File
@@ -231,6 +231,12 @@ type Querier interface {
// Update-only by design: a lapse never creates a row. Absence of a row means
// the key was never conferred; granted = FALSE means conferred, then lapsed.
LapseBooleanEntitlement(ctx context.Context, arg LapseBooleanEntitlementParams) (int64, error)
// core.subscription_displacements applied to every subscription of the billing
// account on the pool (displaced-subscription-waits D4), for the organization
// page's Billing card: one statement for the account, never one per
// subscription. The aliases repeat the function's columns because sqlc cannot
// read a function's RETURNS TABLE. The status filter stays in Go.
ListAccountSubscriptionDisplacements(ctx context.Context, arg ListAccountSubscriptionDisplacementsParams) ([]ListAccountSubscriptionDisplacementsRow, error)
ListActiveEntitlementSets(ctx context.Context) ([]EntitlementSet, error)
// Read-only per-pool boolean state for a batch of pools, the boolean half of
// the commit's before and after readings.
@@ -83,3 +83,23 @@ FROM core.subscription_displacements(sqlc.arg(subscription_id)::uuid, sqlc.arg(p
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
ORDER BY pl.name, d.plan_ladder_id::uuid, d.holder_provision_id::uuid;
-- name: ListAccountSubscriptionDisplacements :many
-- core.subscription_displacements applied to every subscription of the billing
-- account on the pool (displaced-subscription-waits D4), for the organization
-- page's Billing card: one statement for the account, never one per
-- subscription. 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
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)
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;
+5 -2
View File
@@ -675,7 +675,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.
// them). It reads no row, and it is exported because the organization page
// applies 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 {
@@ -732,7 +733,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.
// 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.
func DisplacedProducts(missing []string, rungs map[string][]Rung, displacements []Displacement) map[string]Displacement {
byLadder := make(map[string]Displacement, len(displacements))
for _, d := range displacements {
+225 -2
View File
@@ -20,6 +20,7 @@ import (
"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/web"
wf "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/queues"
@@ -340,6 +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
DisplacementError string
HasInvoice bool
LatestInvoiceID string
// LatestInvoiceNumber is Stripe's customer-facing invoice number
@@ -445,6 +453,124 @@ func subscriptionPeriodNote(state string, cancelAtPeriodEnd bool, periodEnd sql.
return verb + " " + periodEnd.Time.Format("Jan 2, 2006")
}
// 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.
func subscriptionMayBeDisplaced(status string) bool {
switch status {
case "active", "trialing", "past_due", "unpaid", "paused":
return true
}
return false
}
// displacedLadder is a ladder a subscription delivers nothing on.
type displacedLadder struct {
ID string
Name string
}
// displacedHolder is the position that holds a displaced ladder.
type displacedHolder struct {
ProvisionID string
Source string
}
// displacedSubscription is one subscription's displaced ladders and the
// positions that hold them.
type displacedSubscription struct {
Ladders []displacedLadder
Holders []displacedHolder
}
// displacedOnPage applies the reconcile's rule to a subscription's current
// products: every product is taken as missing (a product with a live
// provision is on no displaced ladder, design D2), and the ladder of each
// displacement the rule picks is named. A displacement whose ladder no
// current product names produces nothing. It reads no row.
func displacedOnPage(products []string, rungs map[string][]fulfillment.Rung, displacements []fulfillment.Displacement) displacedSubscription {
left := fulfillment.DisplacedProducts(products, rungs, displacements)
var out displacedSubscription
for _, p := range products {
d, ok := left[p]
if !ok {
continue
}
out.Ladders = append(out.Ladders, displacedLadder{ID: d.LadderID, Name: d.LadderName})
out.Holders = append(out.Holders, displacedHolder{ProvisionID: d.HolderProvisionID, Source: d.HolderSource})
}
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 {
var ladders []displacedLadder
var holders []displacedHolder
seenLadder := make(map[string]bool)
seenHolder := make(map[string]bool)
count := 0
for _, d := range displaced {
if len(d.Ladders) == 0 {
continue
}
count++
for _, l := range d.Ladders {
if !seenLadder[l.ID] {
seenLadder[l.ID] = true
ladders = append(ladders, l)
}
}
for _, h := range d.Holders {
if !seenHolder[h.ProvisionID] {
seenHolder[h.ProvisionID] = true
holders = append(holders, h)
}
}
}
if count == 0 {
return ""
}
slices.SortFunc(ladders, func(a, b displacedLadder) int {
if c := strings.Compare(a.Name, b.Name); c != 0 {
return c
}
return strings.Compare(a.ID, b.ID)
})
names := make([]string, len(ladders))
for i, l := range ladders {
names[i] = l.Name
}
tail := "which " + displacementHolder(holders)
if subscriptions == 1 {
return "Delivers nothing on " + joinWithAnd(names) + ", " + tail
}
verb := "deliver"
if count == 1 {
verb = "delivers"
}
return fmt.Sprintf("%d %s nothing on %s, %s", count, verb, joinWithAnd(names), tail)
}
// displacementHolder words the holders of the displaced ladders: by source
// when one provision holds them all, "other plans hold" when several do.
func displacementHolder(holders []displacedHolder) string {
if len(holders) != 1 {
return "other plans hold"
}
switch holders[0].Source {
case "grant":
return "a grant holds"
case "subscription":
return "another subscription holds"
}
return "another plan holds"
}
// MemberRowViewModel represents one enrolled person on the per-org composite's
// Members section. The PersonID drives the link to /operator/persons/{personID}
// (closes the deferred 3.7 link from operator-mpa-conversion).
@@ -1326,7 +1452,7 @@ func (h *OperatorPartialsHandler) loadOrgEnrollmentData(r *http.Request, orgID s
data.IssuanceProducts, data.ProductsError = h.loadIssuanceProducts(r, pools)
data.IssueGrantForm = issueGrantFormView(orgID, data.IssuanceProducts, forms.ModeRecord, forms.NewValues(), nil, "")
h.loadOrgBillingSummary(r, &data, orgID)
h.loadOrgBillingSummary(r, &data, orgID, pools)
h.loadOrgMembers(r, &data, orgID)
h.loadOrgGrants(r, &data, orgID, names)
h.bindGrantsLedger(&data, grantsLedgerEdit{})
@@ -1564,7 +1690,11 @@ func transitionRow(trn entitlements.ListTransitionsByPoolRow, names *operatorNam
// and the join count is dwarfed by the per-pool reads of the pools panel). The
// subscription fetch is plural because the card reports a count and a worst
// state, which a single latest row cannot produce (design D5, round 5).
func (h *OperatorPartialsHandler) loadOrgBillingSummary(r *http.Request, data *OrgEnrollmentData, orgID string) {
//
// 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.
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 {
acct := accounts[0]
@@ -1575,6 +1705,7 @@ func (h *OperatorPartialsHandler) loadOrgBillingSummary(r *http.Request, data *O
}
if subs, err := h.BillingQ.GetSubscriptionsByBillingAccountID(r.Context(), acct.BillingAccountID); err == nil {
summary.SubscriptionCount, summary.SubscriptionState, summary.SubscriptionNote = summarizeSubscriptions(subs)
h.fillDisplacement(r, &summary, orgID, pools, subs)
}
if inv, err := h.BillingQ.GetLatestInvoiceByBillingAccountID(r.Context(), acct.BillingAccountID); err == nil {
summary.HasInvoice = true
@@ -1606,6 +1737,98 @@ 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.
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)
if err != nil {
h.Logger.Warn("org enrollment: subscription displacement read failed",
slog.String("org_id", orgID),
slog.String("billing_account_id", summary.BillingAccountID),
slog.Any("error", err))
summary.DisplacementError = "Failed to load deliveries."
return
}
summary.DisplacementNote = note
}
// defaultPoolID returns the id of the organization's default pool among its
// active pools, or "" when it has none.
func defaultPoolID(pools []entitlements.ResourcePool) string {
for _, p := range pools {
if p.PoolType == "default" && p.Status == "active" {
return p.PoolID
}
}
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) {
items, err := h.BillingQ.ListCurrentSubscriptionItemsByBillingAccountID(r.Context(), accountID)
if err != nil {
return "", fmt.Errorf("list current subscription items: %w", err)
}
var all []string
for _, it := range items {
all = append(all, it.ProductID)
}
shapes, err := h.EntitlementsQ.ListConferralShapes(r.Context(), all)
if err != nil {
return "", fmt.Errorf("list conferral shapes: %w", err)
}
rows, err := h.EntitlementsQ.ListAccountSubscriptionDisplacements(r.Context(), entitlements.ListAccountSubscriptionDisplacementsParams{
PoolID: poolID,
BillingAccountID: accountID,
})
if err != nil {
return "", fmt.Errorf("list account subscription displacements: %w", err)
}
return displacementLineFor(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)
}
rungs := fulfillment.RungsByProduct(shapes)
bySub := make(map[string][]fulfillment.Displacement)
for _, d := range rows {
bySub[d.SubscriptionID] = append(bySub[d.SubscriptionID], fulfillment.Displacement{
LadderID: d.PlanLadderID,
LadderName: d.LadderName,
DisplacedProductID: d.DisplacedProductID,
HolderProvisionID: d.HolderProvisionID,
HolderSource: d.HolderSource,
})
}
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 {
displaced = append(displaced, d)
}
}
return displacementNote(len(subs), displaced)
}
// loadOrgMembers fills the members section. Each row links to
// /operator/persons/{personID} (closes the deferred 3.7 task from
// operator-mpa-conversion).
@@ -3,7 +3,7 @@
package server
// The organization page characterized, state by state: for each of nine
// The organization page characterized, state by state: for each of ten
// states, the data loadOrgEnrollmentData returns and the
// operator_enrollment.html render of it, compared byte for byte with the files
// under testdata/org_enrollment. The data leaves out the Issue grant form and
@@ -157,7 +157,7 @@ func buildEnrollmentRich(b *enrollmentBuilder) world.Org {
// request carries.
type enrollmentState struct{ name, orgID, query string }
// enrollmentFixture is the nine states, built once.
// enrollmentFixture is the ten states, built once.
type enrollmentFixture struct {
*enrollmentBuilder
states []enrollmentState
@@ -216,6 +216,12 @@ func newEnrollmentFixture(t *testing.T) *enrollmentFixture {
w.Suspend(sub, "past_due")
add("suspended-subscription", o.ID, "")
o = f.org("grant-over-subscription", plain)
sub = w.Subscribe(o, world.SubOpts{PeriodEnd: noon(2099, time.January, 15)}, small2)
f.norm.Name("subscription", "grant-over", sub.ID)
f.grant("Small2", w.Issue(o, small2, world.IssueOpts{}))
add("grant-over-subscription", o.ID, "")
lite2 := f.product("Lite2")
f.ladder("Cycle", lite2)
o = f.org("many-changes", plain)
@@ -84,6 +84,14 @@ func (q failingEnrollmentBillingQ) GetSubscriptionsByBillingAccountID(ctx contex
return q.Querier.GetSubscriptionsByBillingAccountID(ctx, billingAccountID)
}
func (q failingEnrollmentBillingQ) ListCurrentSubscriptionItemsByBillingAccountID(ctx context.Context, billingAccountID string) ([]billing.ListCurrentSubscriptionItemsByBillingAccountIDRow, error) {
if q.fail == "ListCurrentSubscriptionItemsByBillingAccountID" {
var none []billing.ListCurrentSubscriptionItemsByBillingAccountIDRow
return none, errReadFailed
}
return q.Querier.ListCurrentSubscriptionItemsByBillingAccountID(ctx, billingAccountID)
}
func (q failingEnrollmentBillingQ) GetLatestInvoiceByBillingAccountID(ctx context.Context, billingAccountID string) (billing.Invoice, error) {
if q.fail == "GetLatestInvoiceByBillingAccountID" {
var none billing.Invoice
@@ -161,6 +169,22 @@ func (q failingEnrollmentEntitlementsQ) CountEntitlementSetChangeEffectsByOrg(ct
return q.Querier.CountEntitlementSetChangeEffectsByOrg(ctx, orgID)
}
func (q failingEnrollmentEntitlementsQ) ListAccountSubscriptionDisplacements(ctx context.Context, arg entitlements.ListAccountSubscriptionDisplacementsParams) ([]entitlements.ListAccountSubscriptionDisplacementsRow, error) {
if q.fail == "ListAccountSubscriptionDisplacements" {
var none []entitlements.ListAccountSubscriptionDisplacementsRow
return none, errReadFailed
}
return q.Querier.ListAccountSubscriptionDisplacements(ctx, arg)
}
func (q failingEnrollmentEntitlementsQ) ListConferralShapes(ctx context.Context, productIds []string) ([]entitlements.ListConferralShapesRow, error) {
if q.fail == "ListConferralShapes" {
var none []entitlements.ListConferralShapesRow
return none, errReadFailed
}
return q.Querier.ListConferralShapes(ctx, productIds)
}
type failingEnrollmentIdentityQ struct {
identity.Querier
fail string
@@ -285,7 +309,7 @@ func checkEnrollmentBaseline(t *testing.T, r enrollmentRead) {
rankLabels = rankLabels || strings.HasPrefix(trn.FromLabel, "rank ") || strings.HasPrefix(trn.ToLabel, "rank ")
}
switch {
case d.Error != "" || d.TransitionsError != "" || d.EntitlementChangesError != "" || d.ProductsError != "" || d.MembersError != "":
case d.Error != "" || d.TransitionsError != "" || d.EntitlementChangesError != "" || d.ProductsError != "" || d.MembersError != "" || d.BillingSummary.DisplacementError != "":
t.Fatalf("the baseline read failed: %+v", d)
case !ranked || !named || !namedLadders || !namedTransitions || rankLabels:
t.Errorf("baseline rungs ranked %v, deliveries named %v, ladders named %v, actors named %v, rank labels %v", ranked, named, namedLadders, namedTransitions, rankLabels)
@@ -356,12 +380,34 @@ 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 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.
func wantDisplacementError(read string) func(t *testing.T, r enrollmentRead) {
return func(t *testing.T, r enrollmentRead) {
t.Helper()
b := r.data.BillingSummary
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 !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)
}
}
}
func wantEntitlementChangesError(t *testing.T, r enrollmentRead) {
t.Helper()
d := r.data
@@ -11,13 +11,17 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strings"
"testing"
"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/web"
"github.com/google/uuid"
)
// enrollmentFragment renders one template of the operator partials into
@@ -1276,3 +1280,182 @@ func TestSubscriptionUnpaidBadgeIsDanger(t *testing.T) {
}
}
}
// displacedOn is a subscription displaced on the ladders, every one held by
// the same holder.
func displacedOn(holder displacedHolder, ladders ...displacedLadder) displacedSubscription {
d := displacedSubscription{Ladders: ladders}
for range ladders {
d.Holders = append(d.Holders, holder)
}
return d
}
type displacementNoteRow struct {
name string
subscriptions int
displaced []displacedSubscription
want string
}
func displacementNoteRows() []displacementNoteRow {
hosting := displacedLadder{ID: "l-hosting", Name: "Hosting"}
support := displacedLadder{ID: "l-support", Name: "Support"}
docs := displacedLadder{ID: "l-docs", Name: "Docs"}
grant := displacedHolder{ProvisionID: "p-grant", Source: "grant"}
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"},
{"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"},
{"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"},
{"two subscriptions displaced on one ladder", 3, []displacedSubscription{
displacedOn(grant, hosting), displacedOn(grant, hosting),
}, "2 deliver nothing on Hosting, which a grant holds"},
{"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() {
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)
}
})
}
}
type displacedOnPageRow struct {
name string
products []string
rungs map[string][]fulfillment.Rung
displacements []fulfillment.Displacement
want displacedSubscription
}
func displacedOnPageRows() []displacedOnPageRow {
held := func(ladder, name string) fulfillment.Displacement {
return fulfillment.Displacement{LadderID: ladder, LadderName: name, HolderProvisionID: "p-grant", HolderSource: "grant"}
}
hosting := displacedLadder{ID: "l-hosting", Name: "Hosting"}
grant := displacedHolder{ProvisionID: "p-grant", Source: "grant"}
return []displacedOnPageRow{
{"a displacement on a ladder a current product names",
[]string{"tier"}, map[string][]fulfillment.Rung{"tier": {{LadderID: "l-hosting", Rank: 1}}},
[]fulfillment.Displacement{held("l-hosting", "Hosting")},
displacedSubscription{Ladders: []displacedLadder{hosting}, Holders: []displacedHolder{grant}}},
{"the item moved off the ladder",
[]string{"other"}, map[string][]fulfillment.Rung{"other": {{LadderID: "l-support", Rank: 1}}},
[]fulfillment.Displacement{held("l-hosting", "Hosting")},
displacedSubscription{}},
{"the item removed",
nil, map[string][]fulfillment.Rung{"tier": {{LadderID: "l-hosting", Rank: 1}}},
[]fulfillment.Displacement{held("l-hosting", "Hosting")},
displacedSubscription{}},
{"a bundle displaced on one ladder",
[]string{"bundle"}, map[string][]fulfillment.Rung{"bundle": {{LadderID: "l-hosting", Rank: 1}, {LadderID: "l-support", Rank: 1}}},
[]fulfillment.Displacement{held("l-support", "Support")},
displacedSubscription{Ladders: []displacedLadder{{ID: "l-support", Name: "Support"}}, Holders: []displacedHolder{grant}}},
}
}
// 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.
func TestDisplacedOnPage(t *testing.T) {
for _, tc := range displacedOnPageRows() {
t.Run(tc.name, func(t *testing.T) {
if got := displacedOnPage(tc.products, tc.rungs, tc.displacements); !reflect.DeepEqual(got, tc.want) {
t.Errorf("displacedOnPage(%s) = %+v, want %+v", tc.name, got, tc.want)
}
})
}
}
// TestSubscriptionMayBeDisplaced covers every status the subscriptions list
// offers.
func TestSubscriptionMayBeDisplaced(t *testing.T) {
want := map[string]bool{
"active": true, "trialing": true, "past_due": true, "unpaid": true, "paused": true,
"incomplete": false, "incomplete_expired": false, "canceled": false,
}
for _, f := range subscriptionStatusFacets {
t.Run(f.Value, func(t *testing.T) {
w, ok := want[f.Value]
if !ok {
t.Fatalf("status %q has no expectation", f.Value)
}
if got := subscriptionMayBeDisplaced(f.Value); got != w {
t.Errorf("subscriptionMayBeDisplaced(%q) = %v, want %v", f.Value, got, w)
}
})
}
}
const displacementLineForLadder = "6f1b6c1e-0000-4000-8000-000000000001"
type displacementLineForRow struct {
name string
subs []billing.Subscription
rows []entitlements.ListAccountSubscriptionDisplacementsRow
want string
}
func displacementLineForRows() []displacementLineForRow {
displaced := func(sub string) entitlements.ListAccountSubscriptionDisplacementsRow {
return entitlements.ListAccountSubscriptionDisplacementsRow{
SubscriptionID: sub, PlanLadderID: displacementLineForLadder, 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")},
[]entitlements.ListAccountSubscriptionDisplacementsRow{displaced("s1")}, ""},
{"an active subscription has the line", []billing.Subscription{sub("s1", "active")},
[]entitlements.ListAccountSubscriptionDisplacementsRow{displaced("s1")},
"Delivers nothing on Hosting, which a grant holds"},
{"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"},
}
}
// TestDisplacementLineFor pins that a canceled subscription, which keeps its
// current items and its displacement row, gets no line.
func TestDisplacementLineFor(t *testing.T) {
shapes := []entitlements.ListConferralShapesRow{{
ProductID: "tier",
PlanLadderID: uuid.NullUUID{UUID: uuid.MustParse(displacementLineForLadder), Valid: true},
Rank: sql.NullInt32{Int32: 1, Valid: true},
}}
for _, tc := range displacementLineForRows() {
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)
}
})
}
}
@@ -150,6 +150,14 @@ var readCountPages = []readCountPage{
p := w.Product(fmt.Sprintf("Counted %d", n))
w.Ladder(fmt.Sprintf("Counted %d", n), p)
w.Issue(o, p, world.IssueOpts{})
// A subscription and a grant of its tier over it, on a ladder of
// its own, as the grant-over-subscription golden builds it: the
// page runs no statement per subscription, displaced or not.
held := w.Product(fmt.Sprintf("Displaced %d", n))
w.Price(held, 1000)
w.Ladder(fmt.Sprintf("Displaced %d", n), held)
w.Subscribe(o, world.SubOpts{}, held)
w.Issue(o, held, world.IssueOpts{})
w.RuleChange(p, enrollmentLimit(goldenSitesKey, int64(n)), w.Person(fmt.Sprintf("Editor %d", n)), "")
}, "/operator/organizations/" + o.ID
}},
@@ -7,6 +7,8 @@
"SubscriptionCount": 0,
"SubscriptionState": "",
"SubscriptionNote": "",
"DisplacementNote": "",
"DisplacementError": "",
"HasInvoice": false,
"LatestInvoiceID": "",
"LatestInvoiceNumber": "",
@@ -0,0 +1,523 @@
<div class="d-flex align-items-start justify-content-between gap-3 mb-3 app-page-header">
<div class="app-page-header-text">
<nav aria-label="breadcrumb"><ol class="breadcrumb small mb-1">
<li class="breadcrumb-item"><a href="/operator">Operator</a></li><li class="breadcrumb-item"><a href="/operator/organizations">Organizations</a></li>
<li class="breadcrumb-item active" aria-current="page">grant-over-subscription</li>
</ol></nav>
<h1 class="h2 mb-0">grant-over-subscription</h1>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-3">Owner</dt>
<dd class="col-sm-9">
<a href="/operator/persons/<person:Ann>">Ann</a>
</dd>
</dl>
</div>
</div>
<div class="d-flex align-items-center justify-content-between gap-3 mt-4 mb-2 app-section-header">
<h2 class="h5 mb-0">Billing</h2>
<a href="/operator/billing/accounts?q=grant-over-subscription&#43;" class="btn btn-sm btn-outline-secondary">Full billing</a>
</div>
<div class="card mb-3">
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<div class="text-muted small">Outstanding balance</div>
<div>0.00</div>
</div>
<div class="col-md-4">
<div class="text-muted small">Subscriptions</div>
<div>
<span class="badge text-bg-success">Active</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">
<div class="text-muted small">Latest invoice</div>
<div class="text-muted small">No invoices issued</div>
</div>
</div>
</div>
</div>
<div class="d-flex align-items-center justify-content-between gap-3 mt-4 mb-2 app-section-header">
<h2 class="h5 mb-0">Pools <button type="button" class="btn btn-link p-0 border-0 align-baseline app-help-icon" data-bs-toggle="popover" data-bs-trigger="click" data-bs-title="Pools" data-bs-content="A pool holds what an organization&#39;s plan and grants deliver; its sites and services draw from it." aria-label="Help: Pools"><svg class="form-help-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></button></h2>
</div>
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h3 class="h6 mb-1">
Default <span class="badge text-bg-success">Active</span>
</h3>
</div>
</div>
<div class="mt-2">
<p class="mb-0"><strong>Small2</strong></p>
<p class="mb-0 small text-muted">
<span title="since <today> <hh:mm>">Box, rank 0</span>
</p>
</div>
</div>
</div>
<div class="d-flex align-items-center justify-content-between gap-3 mt-4 mb-2 app-section-header">
<h2 class="h5 mb-0">Grants</h2>
</div>
<div id="plan-grants-panel">
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center gap-2">
<ul class="nav nav-tabs card-header-tabs">
<li class="nav-item"><a class="nav-link active" href="/operator/organizations/<org:grant-over-subscription>" hx-get="/operator/organizations/<org:grant-over-subscription>" hx-target="#plan-grants-panel" hx-select="#plan-grants-panel" hx-select-oob="#tier-changes-panel,#entitlement-changes-panel" hx-swap="outerHTML" hx-push-url="true">Active</a></li>
<li class="nav-item"><a class="nav-link " href="/operator/organizations/<org:grant-over-subscription>?tab=history" hx-get="/operator/organizations/<org:grant-over-subscription>?tab=history" hx-target="#plan-grants-panel" hx-select="#plan-grants-panel" hx-select-oob="#tier-changes-panel,#entitlement-changes-panel" hx-swap="outerHTML" hx-push-url="true">History</a></li>
</ul>
<div class="flex-shrink-0">
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-toggle="collapse" data-bs-target="#issueGrantPanel" aria-expanded="false" aria-controls="issueGrantPanel"><svg class="app-opener-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true" focusable="false"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Issue grant</button>
</div>
</div>
<div class="card-body">
<div class="collapse" id="issueGrantPanel">
<div class="card app-form-box mb-3"><div class="card-body">
<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:grant-over-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>
<div class="app-form-fields">
<div id="form-operator.enrollment.grant.issue-product_id" class="mb-3 app-form-field">
<label class="form-label" for="form-operator.enrollment.grant.issue-product_id-control">Product</label>
<select class="form-select" id="form-operator.enrollment.grant.issue-product_id-control" name="product_id" required aria-describedby="form-operator.enrollment.grant.issue-product_id-warning" hx-get="/partials/operator/products/rule-check" hx-swap="innerHTML" hx-target="#form-operator\.enrollment\.grant\.issue-product_id-warning" hx-trigger="change">
<option value="" disabled selected>Choose a product</option>
<option value="<product:Basic>">Basic</option>
<option value="<product:Forum>">Forum</option>
<option value="<product:Forum2>">Forum2</option>
<option value="<product:Large>">Large</option>
<option value="<product:Lite>">Lite</option>
<option value="<product:Lite2>">Lite2</option>
<option value="<product:Mid>">Mid</option>
<option value="<product:Plus>">Plus</option>
<option value="<product:Pro>">Pro</option>
<option value="<product:Small>">Small</option>
<option value="<product:Small2>">Small2</option>
<option value="<product:Starter>">Starter</option>
<option value="<product:Starter2>">Starter2</option>
<option value="<product:Top>">Top</option>
</select>
<div id="form-operator.enrollment.grant.issue-product_id-warning"></div>
</div><div id="form-operator.enrollment.grant.issue-quantity" class="mb-3 app-form-field">
<label class="form-label" for="form-operator.enrollment.grant.issue-quantity-control">Quantity <span class="text-muted">(optional)</span></label> <button type="button" class="btn btn-link p-0 border-0 align-baseline app-help-icon" data-bs-toggle="popover" data-bs-trigger="click" data-bs-title="Quantity" data-bs-content="Seats, for per-seat products." aria-label="Help: Quantity"><svg class="form-help-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></button>
<input type="number" class="form-control" id="form-operator.enrollment.grant.issue-quantity-control" name="quantity" value="" placeholder="1" min="1" max="1000000">
</div><div id="form-operator.enrollment.grant.issue-valid_until" class="mb-3 app-form-field">
<label class="form-label" for="form-operator.enrollment.grant.issue-valid_until-control">Valid until <span class="text-muted">(optional)</span></label> <button type="button" class="btn btn-link p-0 border-0 align-baseline app-help-icon" data-bs-toggle="popover" data-bs-trigger="click" data-bs-title="Valid until" data-bs-content="Leave blank for a grant with no end date." aria-label="Help: Valid until"><svg class="form-help-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></button>
<input type="datetime-local" class="form-control" id="form-operator.enrollment.grant.issue-valid_until-control" name="valid_until" value="" >
</div><div id="form-operator.enrollment.grant.issue-reason" class="mb-3 app-form-field">
<label class="form-label" for="form-operator.enrollment.grant.issue-reason-control">Reason</label>
<select class="form-select" id="form-operator.enrollment.grant.issue-reason-control" name="reason" required>
<option value="" disabled selected>Choose a reason</option>
<option value="manual">manual</option>
<option value="evaluation">evaluation</option>
<option value="promotional">promotional</option>
<option value="complimentary">complimentary</option>
<option value="sponsored">sponsored</option>
<option value="board_decision">board_decision</option>
<option value="legacy">legacy</option>
</select>
</div><div id="form-operator.enrollment.grant.issue-description" class="mb-3 app-form-field">
<label class="form-label" for="form-operator.enrollment.grant.issue-description-control">Note <span class="text-muted">(optional)</span></label> <button type="button" class="btn btn-link p-0 border-0 align-baseline app-help-icon" data-bs-toggle="popover" data-bs-trigger="click" data-bs-title="Note" data-bs-content="Why this grant is issued. Shown with the grant in its history." aria-label="Help: Note"><svg class="form-help-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></button>
<input type="text" class="form-control" id="form-operator.enrollment.grant.issue-description-control" name="description" value="" maxlength="500" autocomplete="off">
</div><div id="form-operator.enrollment.grant.issue-resumes_after_supersession" class="mb-3 app-form-field">
<label class="form-label" for="form-operator.enrollment.grant.issue-resumes_after_supersession-control">Resumes</label> <button type="button" class="btn btn-link p-0 border-0 align-baseline app-help-icon" data-bs-toggle="popover" data-bs-trigger="click" data-bs-title="Resumes" data-bs-content="If a plan replaces this grant, the grant delivers again when that plan ends." aria-label="Help: Resumes"><svg class="form-help-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></button>
<div class="app-form-check-control"><input class="form-check-input" type="checkbox" id="form-operator.enrollment.grant.issue-resumes_after_supersession-control" name="resumes_after_supersession" value="true" ></div>
</div>
</div>
<div class="d-flex flex-wrap gap-2 app-form-commit">
<button type="submit" class="btn btn-primary"><span class="spinner-border spinner-border-sm htmx-indicator me-1" aria-hidden="true"></span>Issue grant</button>
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="collapse" data-bs-target="#issueGrantPanel">Cancel</button>
</div>
</form>
</div></div>
</div>
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<form id="form-operator.list.controls" data-form="operator.list.controls" data-form-kind="search" class="app-form app-form-bar" method="GET" action="/operator/organizations/<org:grant-over-subscription>" role="search" hx-get="/operator/organizations/<org:grant-over-subscription>" hx-target="#plan-grants-panel" hx-swap="outerHTML" hx-push-url="true" hx-select="#plan-grants-panel" hx-select-oob="#tier-changes-panel,#entitlement-changes-panel">
<div id="form-operator.list.controls-error" class="app-form-error" role="alert"></div>
<label class="form-label visually-hidden" for="form-operator.list.controls-q-control">Search this list</label>
<div class="input-group">
<span class="input-group-text"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true"><path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.44.44.98.98l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.98-.98zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0"/></svg></span>
<input type="text" class="form-control" id="form-operator.list.controls-q-control" name="q" value="" placeholder="Product or reason" autocomplete="off">
<button type="submit" class="btn btn-outline-secondary"><span class="spinner-border spinner-border-sm htmx-indicator me-1" aria-hidden="true"></span>Search</button>
</div>
</form>
</div>
<div id="grants-ledger">
<form id="form-operator.enrollment.grant.manage" data-form="operator.enrollment.grant.manage" data-form-kind="batch" class="app-form" novalidate hx-post="/partials/operator/organizations/<org:grant-over-subscription>/grants/ledger" hx-target="#grants-ledger" hx-swap="outerHTML" hx-disable="find button[type=submit]" hx-indicator="find .htmx-indicator">
<div id="form-operator.enrollment.grant.manage-error" class="app-form-error" role="alert"></div>
<div class="table-responsive app-scrollbox">
<table class="table table-hover table-sm table-record align-middle">
<thead>
<tr>
<th>Product</th>
<th>Reason</th>
<th>Issuance <button type="button" class="btn btn-link p-0 border-0 align-baseline app-help-icon" data-bs-toggle="popover" data-bs-trigger="click" data-bs-title="Issuance" data-bs-content="What the record says happened to the grant: active, revoked, or expired." aria-label="Help: Issuance"><svg class="form-help-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></button></th>
<th>Delivery <button type="button" class="btn btn-link p-0 border-0 align-baseline app-help-icon" data-bs-toggle="popover" data-bs-trigger="click" data-bs-title="Delivery" data-bs-content="Whether the grant is delivering into the pool now." aria-label="Help: Delivery"><svg class="form-help-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></button></th>
<th>Resumes <button type="button" class="btn btn-link p-0 border-0 align-baseline app-help-icon" data-bs-toggle="popover" data-bs-trigger="click" data-bs-title="Resumes" data-bs-content="If a plan replaces this grant, the grant delivers again when that plan ends." aria-label="Help: Resumes"><svg class="form-help-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></button></th>
<th>Granted by</th>
<th>Qty</th>
<th>Activated</th>
<th>Valid until</th>
<th class="text-end"><span class="visually-hidden">Actions</span></th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Small2</th>
<td class="text-break"><small class="text-muted">complimentary</small></td>
<td><small>Active</small></td>
<td><span class="badge text-bg-success">Live</span></td>
<td>No</td>
<td><a href="/operator/persons/<person:Ann>">Ann</a></td>
<td>1</td>
<td class="text-nowrap"><small><span class="d-block"><today></span><span class="d-block text-muted"><hh:mm></span></small></td>
<td class="text-nowrap"><small>—</small></td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-secondary ms-1" hx-post="/partials/operator/organizations/<org:grant-over-subscription>/grants/ledger" hx-vals='{"act":"manage","key":"<grant:Small2>"}' hx-include="closest form" hx-target="#grants-ledger" hx-swap="outerHTML" aria-expanded="false"><svg class="app-opener-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true" focusable="false"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Manage</button>
</td>
</tr>
</tbody>
</table>
</div>
</form>
</div>
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mt-2">
<p class="small text-body-secondary mb-0">Showing 1–1 of 1</p>
<p class="small text-body-secondary mb-0">Per page:
<strong>10</strong>
<a href="/operator/organizations/<org:grant-over-subscription>?per=25" hx-get="/operator/organizations/<org:grant-over-subscription>?per=25" hx-target="#plan-grants-panel" hx-select="#plan-grants-panel" hx-select-oob="#tier-changes-panel,#entitlement-changes-panel" hx-swap="outerHTML" hx-push-url="true">25</a>
<a href="/operator/organizations/<org:grant-over-subscription>?per=50" hx-get="/operator/organizations/<org:grant-over-subscription>?per=50" hx-target="#plan-grants-panel" hx-select="#plan-grants-panel" hx-select-oob="#tier-changes-panel,#entitlement-changes-panel" hx-swap="outerHTML" hx-push-url="true">50</a>
</p>
</div>
</div>
</div>
</div>
<div class="d-flex align-items-center justify-content-between gap-3 mt-4 mb-2 app-section-header">
<h2 class="h5 mb-0">Members</h2>
</div>
<div class="card mb-3">
<div class="card-body">
<div class="text-center py-4">
<p class="text-muted mb-2">No members.</p>
</div>
</div>
</div>
<div class="d-flex align-items-center justify-content-between gap-3 mt-4 mb-2 app-section-header">
<h2 class="h5 mb-0">Entitlement changes</h2>
</div>
<div id="entitlement-changes-panel">
<div class="card mb-3">
<div class="card-body">
<div class="text-center py-4">
<p class="text-muted mb-2">No entitlement changes recorded yet.</p>
</div>
</div>
</div>
</div>
<div class="d-flex align-items-center justify-content-between gap-3 mt-4 mb-2 app-section-header">
<h2 class="h5 mb-0">Tier changes <button type="button" class="btn btn-link p-0 border-0 align-baseline app-help-icon" data-bs-toggle="popover" data-bs-trigger="click" data-bs-title="Tier changes" data-bs-content="Every tier change on this organization&#39;s plan ladders, newest first. Tier names reflect the ladder&#39;s current shape." aria-label="Help: Tier changes"><svg class="form-help-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></button></h2>
</div>
<div id="tier-changes-panel">
<div class="card mb-3">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover table-sm table-record">
<thead>
<tr>
<th>Ladder</th>
<th>Change</th>
<th>From</th>
<th>To</th>
<th>By</th>
<th>Reason</th>
<th>When</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Box</th>
<td><span class="badge text-bg-light border" title="Same tier, different funding source: another grant or subscription took over this placement.">Transferred<span class="visually-hidden">, Same tier, different funding source: another grant or subscription took over this placement.</span></span></td>
<td>Small2</td>
<td>Small2</td>
<td>
Ann
</td>
<td class="text-break" title="test fixture">Complimentary</td>
<td class="text-nowrap"><small><span class="d-block"><today></span><span class="d-block text-muted"><hh:mm></span></small></td>
</tr>
<tr>
<th scope="row">Box</th>
<td><span class="badge text-bg-light border">Started</span></td>
<td>—</td>
<td>Small2</td>
<td>
<em class="text-muted">webhook</em>
</td>
<td class="text-break" title="test fixture">test fixture</td>
<td class="text-nowrap"><small><span class="d-block"><today></span><span class="d-block text-muted"><hh:mm></span></small></td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mt-2">
<p class="small text-body-secondary mb-0">Showing 1–2 of 2</p>
<p class="small text-body-secondary mb-0">Per page:
<strong>10</strong>
<a href="/operator/organizations/<org:grant-over-subscription>?tc_per=25" hx-get="/operator/organizations/<org:grant-over-subscription>?tc_per=25" hx-target="#tier-changes-panel" hx-select="#tier-changes-panel" hx-select-oob="#plan-grants-panel,#entitlement-changes-panel" hx-swap="outerHTML" hx-push-url="true">25</a>
<a href="/operator/organizations/<org:grant-over-subscription>?tc_per=50" hx-get="/operator/organizations/<org:grant-over-subscription>?tc_per=50" hx-target="#tier-changes-panel" hx-select="#tier-changes-panel" hx-select-oob="#plan-grants-panel,#entitlement-changes-panel" hx-swap="outerHTML" hx-push-url="true">50</a>
</p>
</div>
</div>
</div>
</div>
@@ -0,0 +1,278 @@
{
"ActionError": "",
"BillingSummary": {
"HasAccount": true,
"BillingAccountID": "<uuid>",
"AccountStatus": "active",
"SubscriptionCount": 1,
"SubscriptionState": "active",
"SubscriptionNote": "renews Jan 15, 2099",
"DisplacementNote": "Delivers nothing on Box, which a grant holds",
"DisplacementError": "",
"HasInvoice": false,
"LatestInvoiceID": "",
"LatestInvoiceNumber": "",
"LatestInvoiceDate": "",
"InvoiceState": "",
"OutstandingBalance": 0,
"OutstandingBalanceFormatted": "0.00",
"Currency": ""
},
"EntitlementChanges": [],
"EntitlementChangesError": "",
"EntitlementChangesNav": {
"BasePath": "/operator/organizations/<org:grant-over-subscription>",
"SearchPlaceholder": "",
"FacetParam": "",
"FacetOptions": null,
"Q": "",
"Facet": "",
"Page": 1,
"Total": 0,
"ParamPrefix": "ec_",
"Extra": {},
"PerPage": 10,
"DefaultPerPage": 10,
"PerPageOptions": [
10,
25,
50
],
"Target": "#entitlement-changes-panel",
"SyncSelect": "#plan-grants-panel,#tier-changes-panel"
},
"EntitlementChangesTotal": 0,
"Error": "",
"GrantsError": "",
"GrantsListQuery": "",
"GrantsNav": {
"BasePath": "/operator/organizations/<org:grant-over-subscription>",
"SearchPlaceholder": "Product or reason",
"FacetParam": "tab",
"FacetOptions": [
{
"Value": "",
"Label": "Active"
},
{
"Value": "history",
"Label": "History"
}
],
"Q": "",
"Facet": "",
"Page": 1,
"Total": 1,
"ParamPrefix": "",
"Extra": {},
"PerPage": 10,
"DefaultPerPage": 10,
"PerPageOptions": [
10,
25,
50
],
"Target": "#plan-grants-panel",
"SyncSelect": "#tier-changes-panel,#entitlement-changes-panel"
},
"GrantsTotal": 1,
"IssuanceProducts": [
{
"ProductID": "<product:Basic>",
"Name": "Basic",
"IsInternal": false,
"DisplayCategory": "",
"SupersedesSubscription": false
},
{
"ProductID": "<product:Forum>",
"Name": "Forum",
"IsInternal": false,
"DisplayCategory": "",
"SupersedesSubscription": false
},
{
"ProductID": "<product:Forum2>",
"Name": "Forum2",
"IsInternal": false,
"DisplayCategory": "",
"SupersedesSubscription": false
},
{
"ProductID": "<product:Large>",
"Name": "Large",
"IsInternal": false,
"DisplayCategory": "",
"SupersedesSubscription": false
},
{
"ProductID": "<product:Lite>",
"Name": "Lite",
"IsInternal": false,
"DisplayCategory": "",
"SupersedesSubscription": false
},
{
"ProductID": "<product:Lite2>",
"Name": "Lite2",
"IsInternal": false,
"DisplayCategory": "",
"SupersedesSubscription": false
},
{
"ProductID": "<product:Mid>",
"Name": "Mid",
"IsInternal": false,
"DisplayCategory": "",
"SupersedesSubscription": false
},
{
"ProductID": "<product:Plus>",
"Name": "Plus",
"IsInternal": false,
"DisplayCategory": "",
"SupersedesSubscription": false
},
{
"ProductID": "<product:Pro>",
"Name": "Pro",
"IsInternal": false,
"DisplayCategory": "",
"SupersedesSubscription": false
},
{
"ProductID": "<product:Small>",
"Name": "Small",
"IsInternal": false,
"DisplayCategory": "",
"SupersedesSubscription": false
},
{
"ProductID": "<product:Small2>",
"Name": "Small2",
"IsInternal": false,
"DisplayCategory": "",
"SupersedesSubscription": false
},
{
"ProductID": "<product:Starter>",
"Name": "Starter",
"IsInternal": false,
"DisplayCategory": "",
"SupersedesSubscription": false
},
{
"ProductID": "<product:Starter2>",
"Name": "Starter2",
"IsInternal": false,
"DisplayCategory": "",
"SupersedesSubscription": false
},
{
"ProductID": "<product:Top>",
"Name": "Top",
"IsInternal": false,
"DisplayCategory": "",
"SupersedesSubscription": false
}
],
"Members": [],
"MembersCapped": false,
"MembersError": "",
"MembersTotal": 0,
"OrgID": "<org:grant-over-subscription>",
"OrgKey": "",
"OrgName": "grant-over-subscription",
"OrgOwnerName": "Ann",
"OrgOwnerPersonID": "<person:Ann>",
"PoolMissing": false,
"Pools": [
{
"PoolID": "<pool:grant-over-subscription>",
"PoolName": "Default",
"PoolType": "default",
"Status": "active",
"Deliveries": [
{
"ProvisionID": "<provision:Small2>",
"ProductName": "Small2",
"ProductID": "<product:Small2>",
"Rungs": [
{
"LadderName": "Box",
"Rank": 0,
"ActivatedAt": "<today> <hh:mm>"
}
],
"Suspended": false
}
],
"Usage": null,
"HasAttachment": true
}
],
"ProductsError": "",
"TierChangesNav": {
"BasePath": "/operator/organizations/<org:grant-over-subscription>",
"SearchPlaceholder": "",
"FacetParam": "",
"FacetOptions": null,
"Q": "",
"Facet": "",
"Page": 1,
"Total": 2,
"ParamPrefix": "tc_",
"Extra": {},
"PerPage": 10,
"DefaultPerPage": 10,
"PerPageOptions": [
10,
25,
50
],
"Target": "#tier-changes-panel",
"SyncSelect": "#plan-grants-panel,#entitlement-changes-panel"
},
"Transitions": [
{
"TransitionID": "<uuid>",
"TransitionType": "transfer",
"LadderName": "Box",
"ChangeLabel": "Transferred",
"ChangeTooltip": "Same tier, different funding source: another grant or subscription took over this placement.",
"ChangeKind": "",
"FromLabel": "Small2",
"ToLabel": "Small2",
"ActorType": "operator",
"ActorName": "Ann",
"ReasonLabel": "Complimentary",
"ReasonNote": "",
"ReasonRaw": "test fixture",
"EffectiveAt": {
"Date": "<today>",
"Time": "<hh:mm>"
}
},
{
"TransitionID": "<uuid>",
"TransitionType": "initiate",
"LadderName": "Box",
"ChangeLabel": "Started",
"ChangeTooltip": "",
"ChangeKind": "",
"FromLabel": "—",
"ToLabel": "Small2",
"ActorType": "webhook",
"ActorName": "",
"ReasonLabel": "test fixture",
"ReasonNote": "",
"ReasonRaw": "test fixture",
"EffectiveAt": {
"Date": "<today>",
"Time": "<hh:mm>"
}
}
],
"TransitionsError": "",
"TransitionsTotal": 2
}
@@ -7,6 +7,8 @@
"SubscriptionCount": 0,
"SubscriptionState": "",
"SubscriptionNote": "",
"DisplacementNote": "",
"DisplacementError": "",
"HasInvoice": false,
"LatestInvoiceID": "",
"LatestInvoiceNumber": "",
+2
View File
@@ -7,6 +7,8 @@
"SubscriptionCount": 0,
"SubscriptionState": "",
"SubscriptionNote": "",
"DisplacementNote": "",
"DisplacementError": "",
"HasInvoice": false,
"LatestInvoiceID": "",
"LatestInvoiceNumber": "",
+2
View File
@@ -7,6 +7,8 @@
"SubscriptionCount": 0,
"SubscriptionState": "",
"SubscriptionNote": "",
"DisplacementNote": "",
"DisplacementError": "",
"HasInvoice": false,
"LatestInvoiceID": "",
"LatestInvoiceNumber": "",
@@ -7,6 +7,8 @@
"SubscriptionCount": 0,
"SubscriptionState": "",
"SubscriptionNote": "",
"DisplacementNote": "",
"DisplacementError": "",
"HasInvoice": false,
"LatestInvoiceID": "",
"LatestInvoiceNumber": "",
@@ -7,6 +7,8 @@
"SubscriptionCount": 1,
"SubscriptionState": "canceling",
"SubscriptionNote": "ends Jan 15, 2099",
"DisplacementNote": "",
"DisplacementError": "",
"HasInvoice": true,
"LatestInvoiceID": "<invoice:First>",
"LatestInvoiceNumber": "0001",
+2
View File
@@ -7,6 +7,8 @@
"SubscriptionCount": 1,
"SubscriptionState": "canceling",
"SubscriptionNote": "ends Jan 15, 2099",
"DisplacementNote": "",
"DisplacementError": "",
"HasInvoice": true,
"LatestInvoiceID": "<invoice:First>",
"LatestInvoiceNumber": "0001",
@@ -7,6 +7,8 @@
"SubscriptionCount": 0,
"SubscriptionState": "",
"SubscriptionNote": "",
"DisplacementNote": "",
"DisplacementError": "",
"HasInvoice": false,
"LatestInvoiceID": "",
"LatestInvoiceNumber": "",
@@ -7,6 +7,8 @@
"SubscriptionCount": 1,
"SubscriptionState": "past_due",
"SubscriptionNote": "",
"DisplacementNote": "",
"DisplacementError": "",
"HasInvoice": false,
"LatestInvoiceID": "",
"LatestInvoiceNumber": "",
@@ -152,34 +152,36 @@ 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
- [ ] 6.1 `internal/server/operator_enrollment.go` (design D4; the Copy table, rows O1 to O3, approved before this section starts):
- [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`.
- `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.
- `BillingSummaryViewModel` gains `DisplacementNote string` and `DisplacementError string`, with a comment naming the requirement they render. `loadOrgBillingSummary(r, data, orgID, pools)` takes the pools `loadOrgPools` already read and the organization's default pool from them, so the pool is not read a second time, and runs the three statements above once for the account, never per subscription. With no active default pool among them, the three statements are not run and both fields stay empty. It filters the subscriptions the card already read through `subscriptionMayBeDisplaced`, builds each subscription's `displacedSubscription` with `displacedOnPage` and sets `DisplacementNote`. A failed items, shapes or displacement read logs L3 (`org enrollment: subscription displacement read failed`, attributes `org_id`, `billing_account_id`, `error`) and sets `DisplacementError` to O3, leaving `DisplacementNote` empty (`docs/go-conventions.md`, "A failed read is never an answer").
- [ ] 6.2 `internal/embeds/templates/partials/operator_enrollment.html`: in the Subscriptions row, after the value line's `<div>`, `{{ if .BillingSummary.DisplacementError }}<div class="text-muted small">{{ .BillingSummary.DisplacementError }}</div>{{ else if .BillingSummary.DisplacementNote }}<div class="text-muted small">{{ .BillingSummary.DisplacementNote }}</div>{{ end }}`; the Go-template comment above the rows names the line and the error.
- [ ] 6.3 Tests:
- [x] 6.2 `internal/embeds/templates/partials/operator_enrollment.html`: in the Subscriptions row, after the value line's `<div>`, `{{- if .BillingSummary.DisplacementError }}` with `<div class="small text-danger" role="alert">{{ .BillingSummary.DisplacementError }}</div>` (the error form of `operator_plan_ladder_tier_reorder_message.html`), `{{- else if .BillingSummary.DisplacementNote }}` with `<div class="text-muted small">{{ .BillingSummary.DisplacementNote }}</div>`, `{{- end }}`, each marker on its own line so no other golden changes; the Go-template comment above the rows names the line and the error.
- [x] 6.3 Tests:
- `TestDisplacementNote` (`internal/server/operator_enrollment_render_test.go`, beside `TestSummarizeSubscriptions`), rows through a named function: `a grant holds the ladder` (`Delivers nothing on Hosting, which a grant holds`); `another subscription holds the ladder` (`Delivers nothing on Hosting, which another subscription holds`); `a purchase holds the ladder` (`Delivers nothing on Hosting, which another plan holds`); `one grant holds two ladders` (`Delivers nothing on Hosting and Support, which a grant holds`); `three ladders` (`Delivers nothing on Docs, Hosting and Support, which a grant holds`); `two holders` (`Delivers nothing on Hosting and Support, which other plans hold`); `one of several subscriptions` (`1 delivers nothing on Hosting, which another subscription holds`); `two of several subscriptions` (`2 deliver nothing on Hosting and Support, which other plans hold`); `ladders out of name order` (the ladders handed over as Support, then Hosting; `Delivers nothing on Hosting and Support, which a grant holds`); `two subscriptions displaced on one ladder` (two displaced subscriptions, each naming Hosting with the same grant provision as its holder; `2 deliver nothing on Hosting, which a grant holds`); `nothing displaced` (`""`). A failure reads `displacementNote(%s) = %q, want %q`. The rows give each ladder's id alongside its name.
- `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`.
- `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.
- [ ] 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.
- [ ] 6.5 `make screens`: no changed screen is expected, because the demo seed holds no subscription a grant displaced; a changed screen is a stop to report, never a baseline this change accepts.
- [ ] 6.6 Breaks:
- [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.
- [x] 6.5 `make screens`: no changed screen is expected, because the demo seed holds no subscription a grant displaced; a changed screen is a stop to report, never a baseline this change accepts.
- [x] 6.6 Breaks:
- `DisplacementNote` never set (its assignment in `loadOrgBillingSummary` removed): caught by `TestOrgEnrollmentGolden`, `want_new` `testdata/org_enrollment/grant-over-subscription.html differs from what the code produced`;
- the template without the line: caught by the same golden;
- a subscription holder worded as a grant (`another subscription holds` replaced by `a grant holds`): caught by `TestDisplacementNote`, `want_new` `displacementNote(another subscription holds the ladder) =`;
- several holders worded as one (the several-holders branch removed): caught by `TestDisplacementNote`, `want_new` `displacementNote(two holders) =`;
- the ladders joined with commas only (the `and` before the last name dropped): caught by `TestDisplacementNote`, `want_new` `displacementNote(one grant holds two ladders) =`;
- the ladders joined with commas only (the `and` before the last name dropped, in `joinWithAnd`, `operator_partials.go`): caught by `TestDisplacementNote`, `want_new` `displacementNote(one grant holds two ladders) =`;
- the plural form never chosen: caught by `TestDisplacementNote`, `want_new` `displacementNote(two of several subscriptions) =`;
- the sort in `displacementNote` removed: caught by `TestDisplacementNote`, `want_new` `displacementNote(ladders out of name order) =`;
- the ladders not deduplicated in `displacementNote`: caught by `TestDisplacementNote`, `want_new` `displacementNote(two subscriptions displaced on one ladder) =`;
- the holders not deduplicated: caught by `TestDisplacementNote`, `want_new` `displacementNote(one grant holds two ladders) =`, which reads `which other plans hold` without the dedupe;
- `canceled` admitted by `subscriptionMayBeDisplaced`: caught by `TestSubscriptionMayBeDisplaced`, `want_new` `subscriptionMayBeDisplaced("canceled") = true, want false`;
- `displacedOnPage` reading displacements without the current products (the `DisplacedProducts` call replaced by the first displacement's ladder): caught by `TestDisplacedOnPage`, `want_new` `displacedOnPage(the item moved off the ladder) =`;
- the status filter at the call site removed (`if !subscriptionMayBeDisplaced(s.Status) {` replaced by `if false {` in `displacementLineFor`): caught by `TestDisplacementLineFor`, `want_new` `displacementLineFor(a canceled subscription has no line) =`;
- the displacement read moved into a loop over the subscriptions: caught by the organization page's read-count row, `want_new` `two more rows cost`;
- the `DisplacementError` assignment and the L3 log line removed: caught by `TestOrgEnrollmentReadFailures`, `want_new` `DisplacementError after a failed ListAccountSubscriptionDisplacements read =`.