Refuse a second subscription on a ladder a paused subscription pays for
Ninth section of #269's change, and #277. A grant over a paid tier now pauses the subscription that pays for it, so that subscription pays on a ladder without holding it. The offer decision did not know such a subscription: on a grant-held rung it sold Checkout, and a member could buy a second subscription on a ladder the first still paid for. The offer now reads the paying subscription: on each ladder, the first live subscription by id (active, trialing, past_due, unpaid or paused) whose current item names a tier of the ladder and that does not hold the ladder's position. Rule 3a, ahead of rule 3, sells nothing through Checkout on a ladder a subscription pays for; every target answers none there, and section 10's paid card and section 11's switch give the moves that ladder offers. The Products page, the Billing page, Checkout, the switch and its preview all reach it through one plans.Offer decision. ListSubscriptionRungsByOrg reads the paying subscriptions in one statement per pass over the organization's ladders, cached on the loader; a switch's page after its move reads again. ListLadderPositionsByPool also returns default_sourced. Checkout's refusal logs the price, the ladder and the reason. docs/models/plan-ladders-transitions.md (invariant 19) and docs/models/payments-billing.md (invariant 12) state the rule. Tests: TestOffer rows and TestOfferSellsNothingOnAPayingLadder; TestPayingByLadder, each row in two orders (the holding subscription, the default, a Dashboard pause, a scheduled downgrade, unpaid, two tiers of one subscription); TestCheckoutRefusesALadderASubscriptionPaysFor over an active, past_due, unpaid, paused and trialing displaced subscription (422, the Copy sentence, no Stripe call); TestCheckoutAboveAGrantWithNoSubscription, two controls that sell and a ladder only an ended item names, which sells; TestProductsPageReadsPayingOnce; failed-read rows for the Products page, Checkout and the switch; the golden state paid-plus-under-granted-pro. Review: no blocker or major. Three minors (the new read's failure untested; status, ended-item and two-tier facts untested; a Checkout test that could not tell the offer's refusal from the price check's) and seven nits applied. The change's text now says one read per pass over the ladders, not per request, since a switch's page after its move must read again. Unseen by any test: the default_sourced column read as false, and switchOffer's paying refusals, which section 11's tests reach. Breaks: 15 of 15 caught. Full suite on reset databases: 4890 pass, 14 skip. make screens: no change of this section's; the two organization captures that differ are the grant form's hint, not yet in the accepted baseline. The change's text also states the grant form's two-line state: a chosen tier marked with the warning sign whose entitlement set has no active rule shows the rule-less sentence and the supersession hint, each a different cost of the same choice.
This commit is contained in:
@@ -62,7 +62,7 @@ One gloss first, because four rules below depend on it. A **commitment** is a mi
|
||||
9. **[db-code]** A scheduled change fires exactly once: firing claims the row by flipping scheduled → applied in a conditional update, so a concurrent sweeper and webhook cannot both fire it; the loser sees no row and stops.
|
||||
10. **[app]** An immediate plan switch disarms any pending cancellation: the switch supersedes the intent row and clears Stripe's cancel-at-period-end flag in the same Stripe call that changes the price.
|
||||
11. **[app]** Mid-term downgrades and cancellations dispatch on the commitment policy: `allow` proceeds now; `block` defers the change to the commitment boundary as a scheduled change; `fee` means a termination fee would be owed, and since fee collection is unbuilt, the console refuses the change rather than charging. Upgrades are never gated.
|
||||
12. **[app]** Checkout sells only what the offer decision (`internal/plans`) offers: the default, recurring price of a published, active, public product, with a Stripe mapping the key can reach, and, for a plan tier, only where every ladder the product is a tier of offers Checkout, which excludes a ladder a subscription already pays for and a tier at or below a rung a grant holds. A read it cannot make refuses the request. So neither a stale tab nor a crafted request can buy an unpublished product, a price other than the default, a one-time price, a tier below a grant, or a second concurrent subscription on a ladder.
|
||||
12. **[app]** Checkout sells only what the offer decision (`internal/plans`) offers: the default, recurring price of a published, active, public product, with a Stripe mapping the key can reach, and, for a plan tier, only where every ladder the product is a tier of offers Checkout, which excludes a ladder a subscription already pays for, whether or not that subscription holds the ladder's rung, and a tier at or below a rung a grant holds. A read it cannot make refuses the request. So neither a stale tab nor a crafted request can buy an unpublished product, a price other than the default, a one-time price, a tier below a grant, or a second concurrent subscription on a ladder.
|
||||
13. **[db]** Every payment references an invoice — there are no invoice-less payments.
|
||||
14. **[app]** The webhook receiver acknowledges only what it recorded and started: it answers 2xx once the event row is inserted, or identified as a duplicate of an already-recorded delivery, and the event's workflow has started (a duplicate of a finished event starts nothing), and answers 500 when the insert or the start fails, so Stripe redelivers instead of the event being silently lost. While Temporal is not connected it keeps the recorded row `received` and answers 503 with `Retry-After: 5`; Stripe's redelivery, or the sweep that runs once Temporal connects, starts the workflow. The Discourse receiver answers 500 when its insert fails and 200 for every recorded event: an event whose processing fails, a reconcile that cannot start before Temporal connects included, is recorded `failed`, and the periodic sweep restores what it missed.
|
||||
15. **[db-code]** A finished Stripe event is not processed again. Its workflow id is the event id, and Temporal starts a completed id again, so a redelivery or a sweep that read the row as unfinished can start a second run after the first completed. The activity's first update applies only to a row at `received`, `processing` or `failed`, and the activity returns without dispatching when that update touches no row.
|
||||
|
||||
@@ -76,7 +76,7 @@ An invariant is a rule the model guarantees everywhere; code that would break on
|
||||
16. **[db]** A ladder's display name is unique, compared without regard to case (`uq_plan_ladders_name_ci`). The name is what every catalog and composite surface leads with, so two ladders sharing one would be indistinguishable to an operator. Migration `00012` deduped colliding names before adding the index, and folded the existing ladder keys to the key grammar (lower-cased, separator runs to `_`, leading non-letters removed), clearing any value the grammar still rejected and any that collided after folding.
|
||||
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.
|
||||
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 when no subscription pays on the ladder. On a ladder a subscription pays for without holding its rung, no tier is sold through Checkout: the switch of the paying subscription is offered at or above the grant and above the paid tier, and the paid tier resumes under a lower grant. 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 delivers nothing there. When the plan that holds the ladder ends and the act ended at least one of its provisions (a grant's revocation or expiry, the holding subscription's end, or a live subscription dropping the item that held the ladder), that act confers each such subscription's recorded products in its own transaction, after resumption (invariant 18) and before default restoration, with the reason `subscription-delivery:revocation:`, `subscription-delivery:expiration:`, `subscription-delivery:subscription:` or `subscription-delivery:dropped:` followed by the ending plan's id; a subscription the ending plan superseded goes first, then the rest in subscription id order, a past-due, unpaid or paused subscription comes back suspended, and a refused conferral is logged and leaves that subscription undelivered. In any other case the subscription's next reconcile after no such plan holds the ladder confers it again. While every conferring product the subscription pays for is left alone and every plan holding those ladders is a grant, the subscription's collection is paused at Stripe. The exception: a grant's hold ends when the subscription pays at or above the grant. The reconcile confers a product of the subscription whose shape names such a ladder when the holder is a grant, the product is not the one the grant displaced, and its rank on the ladder is at or above the rank of the grant's product; the grant is then replaced by the subscription. The product the grant displaced never takes the ladder back, whatever its rank. A grant product that is no tier of the ladder never yields. A default-sourced position never holds a ladder against a subscription (invariant 11). `core.subscription_displacements` is the one statement of which ladders those are; the reconcile, the organization page and the operator's subscriptions list read it.
|
||||
|
||||
## Dimensions
|
||||
|
||||
@@ -428,6 +428,16 @@ type Querier interface {
|
||||
// legible for the operator topology overview). Returns one row per
|
||||
// (shared product, ladder) membership, ordered for stable grouping in Go.
|
||||
ListSharedTierProducts(ctx context.Context) ([]ListSharedTierProductsRow, error)
|
||||
// The organization's live subscriptions (active, trialing, past_due, unpaid,
|
||||
// paused), one row per current item and tier row of the item's product, for
|
||||
// the offer decision's fact "a subscription pays for a tier of this ladder"
|
||||
// (grant-pauses-subscription-billing D9): the subscription's status, period
|
||||
// end, cancel_at_period_end, the mirror's collection pause and marker, the
|
||||
// first scheduled cancellation and the first scheduled plan switch (joined as
|
||||
// ListLadderPositionsByPool joins them), and the item's price facts. A product
|
||||
// that is a tier of no ladder has no row. Read once per request, never per
|
||||
// ladder.
|
||||
ListSubscriptionRungsByOrg(ctx context.Context, orgID string) ([]ListSubscriptionRungsByOrgRow, error)
|
||||
// Operator subscriptions view, paged/searched/filtered (operator-list-scale
|
||||
// UX-4; operator-billing-views D8). Carries ba.org_id so the handler can
|
||||
// resolve and link the organization without a cross-module join (see
|
||||
|
||||
@@ -136,3 +136,44 @@ JOIN core.prices p ON p.price_id = si.price_id
|
||||
WHERE s.status = 'active' AND s.pause_collection_behavior IS NULL AND si.ended_at IS NULL AND p.recurring_interval IS NOT NULL
|
||||
GROUP BY p.currency
|
||||
ORDER BY monthly_cents DESC;
|
||||
|
||||
-- name: ListSubscriptionRungsByOrg :many
|
||||
-- The organization's live subscriptions (active, trialing, past_due, unpaid,
|
||||
-- paused), one row per current item and tier row of the item's product, for
|
||||
-- the offer decision's fact "a subscription pays for a tier of this ladder"
|
||||
-- (grant-pauses-subscription-billing D9): the subscription's status, period
|
||||
-- end, cancel_at_period_end, the mirror's collection pause and marker, the
|
||||
-- first scheduled cancellation and the first scheduled plan switch (joined as
|
||||
-- ListLadderPositionsByPool joins them), and the item's price facts. A product
|
||||
-- that is a tier of no ladder has no row. Read once per request, never per
|
||||
-- ladder.
|
||||
SELECT s.subscription_id, s.status, s.current_period_end, s.cancel_at_period_end,
|
||||
s.pause_collection_behavior, s.pause_collection_marked,
|
||||
c.effective_at AS cancellation_at,
|
||||
sw.effective_at AS downgrade_at,
|
||||
tp.product_id AS downgrade_product_id, tp.name AS downgrade_product_name,
|
||||
si.product_id, si.price_id,
|
||||
pr.unit_amount, pr.currency, pr.recurring_interval,
|
||||
t.plan_ladder_id, t.rank
|
||||
FROM core.subscriptions s
|
||||
JOIN core.accounts ba ON ba.billing_account_id = s.billing_account_id
|
||||
JOIN core.subscription_items si ON si.subscription_id = s.subscription_id AND si.ended_at IS NULL
|
||||
JOIN core.prices pr ON pr.price_id = si.price_id
|
||||
JOIN core.plan_ladder_tiers t ON t.product_id = si.product_id
|
||||
LEFT JOIN core.subscription_scheduled_changes c
|
||||
ON c.scheduled_change_id = (
|
||||
SELECT c1.scheduled_change_id FROM core.subscription_scheduled_changes c1
|
||||
WHERE c1.subscription_id = s.subscription_id AND c1.status = 'scheduled'
|
||||
AND c1.change_type = 'cancellation'
|
||||
ORDER BY c1.effective_at, c1.scheduled_change_id LIMIT 1)
|
||||
LEFT JOIN core.subscription_scheduled_changes sw
|
||||
ON sw.scheduled_change_id = (
|
||||
SELECT s1.scheduled_change_id FROM core.subscription_scheduled_changes s1
|
||||
WHERE s1.subscription_id = s.subscription_id AND s1.status = 'scheduled'
|
||||
AND s1.change_type = 'plan_switch' AND s1.target_price_id IS NOT NULL
|
||||
ORDER BY s1.effective_at, s1.scheduled_change_id LIMIT 1)
|
||||
LEFT JOIN core.prices swp ON swp.price_id = sw.target_price_id
|
||||
LEFT JOIN core.products tp ON tp.product_id = swp.product_id
|
||||
WHERE ba.org_id = $1
|
||||
AND s.status IN ('active', 'trialing', 'past_due', 'unpaid', 'paused')
|
||||
ORDER BY t.plan_ladder_id, s.subscription_id, si.product_id;
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
@@ -195,6 +196,109 @@ func (q *Queries) GetSubscriptionsByBillingAccountID(ctx context.Context, billin
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listSubscriptionRungsByOrg = `-- name: ListSubscriptionRungsByOrg :many
|
||||
SELECT s.subscription_id, s.status, s.current_period_end, s.cancel_at_period_end,
|
||||
s.pause_collection_behavior, s.pause_collection_marked,
|
||||
c.effective_at AS cancellation_at,
|
||||
sw.effective_at AS downgrade_at,
|
||||
tp.product_id AS downgrade_product_id, tp.name AS downgrade_product_name,
|
||||
si.product_id, si.price_id,
|
||||
pr.unit_amount, pr.currency, pr.recurring_interval,
|
||||
t.plan_ladder_id, t.rank
|
||||
FROM core.subscriptions s
|
||||
JOIN core.accounts ba ON ba.billing_account_id = s.billing_account_id
|
||||
JOIN core.subscription_items si ON si.subscription_id = s.subscription_id AND si.ended_at IS NULL
|
||||
JOIN core.prices pr ON pr.price_id = si.price_id
|
||||
JOIN core.plan_ladder_tiers t ON t.product_id = si.product_id
|
||||
LEFT JOIN core.subscription_scheduled_changes c
|
||||
ON c.scheduled_change_id = (
|
||||
SELECT c1.scheduled_change_id FROM core.subscription_scheduled_changes c1
|
||||
WHERE c1.subscription_id = s.subscription_id AND c1.status = 'scheduled'
|
||||
AND c1.change_type = 'cancellation'
|
||||
ORDER BY c1.effective_at, c1.scheduled_change_id LIMIT 1)
|
||||
LEFT JOIN core.subscription_scheduled_changes sw
|
||||
ON sw.scheduled_change_id = (
|
||||
SELECT s1.scheduled_change_id FROM core.subscription_scheduled_changes s1
|
||||
WHERE s1.subscription_id = s.subscription_id AND s1.status = 'scheduled'
|
||||
AND s1.change_type = 'plan_switch' AND s1.target_price_id IS NOT NULL
|
||||
ORDER BY s1.effective_at, s1.scheduled_change_id LIMIT 1)
|
||||
LEFT JOIN core.prices swp ON swp.price_id = sw.target_price_id
|
||||
LEFT JOIN core.products tp ON tp.product_id = swp.product_id
|
||||
WHERE ba.org_id = $1
|
||||
AND s.status IN ('active', 'trialing', 'past_due', 'unpaid', 'paused')
|
||||
ORDER BY t.plan_ladder_id, s.subscription_id, si.product_id
|
||||
`
|
||||
|
||||
type ListSubscriptionRungsByOrgRow struct {
|
||||
SubscriptionID string `json:"subscription_id"`
|
||||
Status string `json:"status"`
|
||||
CurrentPeriodEnd sql.NullTime `json:"current_period_end"`
|
||||
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
|
||||
PauseCollectionBehavior sql.NullString `json:"pause_collection_behavior"`
|
||||
PauseCollectionMarked bool `json:"pause_collection_marked"`
|
||||
CancellationAt sql.NullTime `json:"cancellation_at"`
|
||||
DowngradeAt sql.NullTime `json:"downgrade_at"`
|
||||
DowngradeProductID uuid.NullUUID `json:"downgrade_product_id"`
|
||||
DowngradeProductName sql.NullString `json:"downgrade_product_name"`
|
||||
ProductID string `json:"product_id"`
|
||||
PriceID string `json:"price_id"`
|
||||
UnitAmount int32 `json:"unit_amount"`
|
||||
Currency string `json:"currency"`
|
||||
RecurringInterval sql.NullString `json:"recurring_interval"`
|
||||
PlanLadderID string `json:"plan_ladder_id"`
|
||||
Rank int32 `json:"rank"`
|
||||
}
|
||||
|
||||
// The organization's live subscriptions (active, trialing, past_due, unpaid,
|
||||
// paused), one row per current item and tier row of the item's product, for
|
||||
// the offer decision's fact "a subscription pays for a tier of this ladder"
|
||||
// (grant-pauses-subscription-billing D9): the subscription's status, period
|
||||
// end, cancel_at_period_end, the mirror's collection pause and marker, the
|
||||
// first scheduled cancellation and the first scheduled plan switch (joined as
|
||||
// ListLadderPositionsByPool joins them), and the item's price facts. A product
|
||||
// that is a tier of no ladder has no row. Read once per request, never per
|
||||
// ladder.
|
||||
func (q *Queries) ListSubscriptionRungsByOrg(ctx context.Context, orgID string) ([]ListSubscriptionRungsByOrgRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listSubscriptionRungsByOrg, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListSubscriptionRungsByOrgRow{}
|
||||
for rows.Next() {
|
||||
var i ListSubscriptionRungsByOrgRow
|
||||
if err := rows.Scan(
|
||||
&i.SubscriptionID,
|
||||
&i.Status,
|
||||
&i.CurrentPeriodEnd,
|
||||
&i.CancelAtPeriodEnd,
|
||||
&i.PauseCollectionBehavior,
|
||||
&i.PauseCollectionMarked,
|
||||
&i.CancellationAt,
|
||||
&i.DowngradeAt,
|
||||
&i.DowngradeProductID,
|
||||
&i.DowngradeProductName,
|
||||
&i.ProductID,
|
||||
&i.PriceID,
|
||||
&i.UnitAmount,
|
||||
&i.Currency,
|
||||
&i.RecurringInterval,
|
||||
&i.PlanLadderID,
|
||||
&i.Rank,
|
||||
); 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 listSubscriptionsPage = `-- name: ListSubscriptionsPage :many
|
||||
SELECT
|
||||
s.subscription_id,
|
||||
|
||||
@@ -387,7 +387,7 @@ func (q *Queries) ListConferralShapes(ctx context.Context, productIds []string)
|
||||
|
||||
const listLadderPositionsByPool = `-- name: ListLadderPositionsByPool :many
|
||||
SELECT l.plan_ladder_id, l.pool_id, l.product_id, l.provision_id, l.activated_at,
|
||||
l.status AS position_status,
|
||||
l.status AS position_status, l.default_sourced,
|
||||
pd.name AS product_name,
|
||||
p.subscription_id, p.grant_id, p.purchase_id,
|
||||
s.subscription_id AS projected_subscription_id,
|
||||
@@ -427,6 +427,7 @@ type ListLadderPositionsByPoolRow struct {
|
||||
ProvisionID string `json:"provision_id"`
|
||||
ActivatedAt time.Time `json:"activated_at"`
|
||||
PositionStatus string `json:"position_status"`
|
||||
DefaultSourced bool `json:"default_sourced"`
|
||||
ProductName string `json:"product_name"`
|
||||
SubscriptionID uuid.NullUUID `json:"subscription_id"`
|
||||
GrantID uuid.NullUUID `json:"grant_id"`
|
||||
@@ -460,6 +461,10 @@ type ListLadderPositionsByPoolRow struct {
|
||||
//
|
||||
// Nothing filters ended_at or the subscription's status, because no reader does.
|
||||
//
|
||||
// grant_id is set for a default position too, because a default position is a
|
||||
// grant row with grant_reason 'default'; default_sourced tells the two apart
|
||||
// (grant-pauses-subscription-billing D9).
|
||||
//
|
||||
// core.subscriptions is joined LEFT because pool_provisions.subscription_id
|
||||
// carries no foreign key: a provision can name a subscription whose row is not
|
||||
// projected yet, and projected_subscription_id is then NULL. The scheduled
|
||||
@@ -483,6 +488,7 @@ func (q *Queries) ListLadderPositionsByPool(ctx context.Context, poolID string)
|
||||
&i.ProvisionID,
|
||||
&i.ActivatedAt,
|
||||
&i.PositionStatus,
|
||||
&i.DefaultSourced,
|
||||
&i.ProductName,
|
||||
&i.SubscriptionID,
|
||||
&i.GrantID,
|
||||
|
||||
@@ -20,3 +20,25 @@ func PositionsByLadder(rows []ListLadderPositionsByPoolRow) map[string]ListLadde
|
||||
}
|
||||
return byLadder
|
||||
}
|
||||
|
||||
// PayingSubscription picks the subscription that pays on a ladder without
|
||||
// holding its rung: of the live subscriptions that pay for a tier of the
|
||||
// ladder, the one with the lowest id that is not the one backing the held
|
||||
// position, so the pick does not depend on the order the ids arrive in. The
|
||||
// offer decision's paying fact (payingByLadder) chooses through this function
|
||||
// (grant-pauses-subscription-billing D9). held is the ladder's row from
|
||||
// PositionsByLadder, the zero row when the organization holds nothing there;
|
||||
// ok is false when no subscription pays without holding.
|
||||
func PayingSubscription(held ListLadderPositionsByPoolRow, subscriptionIDs []string) (id string, ok bool) {
|
||||
holder := ""
|
||||
if held.SubscriptionID.Valid {
|
||||
holder = held.SubscriptionID.UUID.String()
|
||||
}
|
||||
for _, candidate := range subscriptionIDs {
|
||||
if candidate == holder || (ok && candidate >= id) {
|
||||
continue
|
||||
}
|
||||
id, ok = candidate, true
|
||||
}
|
||||
return id, ok
|
||||
}
|
||||
|
||||
@@ -424,6 +424,10 @@ type Querier interface {
|
||||
//
|
||||
// Nothing filters ended_at or the subscription's status, because no reader does.
|
||||
//
|
||||
// grant_id is set for a default position too, because a default position is a
|
||||
// grant row with grant_reason 'default'; default_sourced tells the two apart
|
||||
// (grant-pauses-subscription-billing D9).
|
||||
//
|
||||
// core.subscriptions is joined LEFT because pool_provisions.subscription_id
|
||||
// carries no foreign key: a provision can name a subscription whose row is not
|
||||
// projected yet, and projected_subscription_id is then NULL. The scheduled
|
||||
|
||||
@@ -202,6 +202,10 @@ GROUP BY product_id;
|
||||
--
|
||||
-- Nothing filters ended_at or the subscription's status, because no reader does.
|
||||
--
|
||||
-- grant_id is set for a default position too, because a default position is a
|
||||
-- grant row with grant_reason 'default'; default_sourced tells the two apart
|
||||
-- (grant-pauses-subscription-billing D9).
|
||||
--
|
||||
-- core.subscriptions is joined LEFT because pool_provisions.subscription_id
|
||||
-- carries no foreign key: a provision can name a subscription whose row is not
|
||||
-- projected yet, and projected_subscription_id is then NULL. The scheduled
|
||||
@@ -210,7 +214,7 @@ GROUP BY product_id;
|
||||
-- cancellation and the first scheduled plan switch with a target price are
|
||||
-- the ones the page draws, with the change id as the tie-break.
|
||||
SELECT l.plan_ladder_id, l.pool_id, l.product_id, l.provision_id, l.activated_at,
|
||||
l.status AS position_status,
|
||||
l.status AS position_status, l.default_sourced,
|
||||
pd.name AS product_name,
|
||||
p.subscription_id, p.grant_id, p.purchase_id,
|
||||
s.subscription_id AS projected_subscription_id,
|
||||
|
||||
@@ -47,6 +47,33 @@ type Position struct {
|
||||
// Paused is true when the position is suspended and its subscription is
|
||||
// paused. Paused implies Suspended.
|
||||
Paused bool
|
||||
// Paying is a live subscription that pays for a tier of this ladder while
|
||||
// it does not hold the ladder's rung. Nil when there is none.
|
||||
Paying *Paying
|
||||
}
|
||||
|
||||
// Paying is a live subscription that pays for a tier of a ladder while it does
|
||||
// not hold the ladder's rung: something else holds it (a grant, the default, a
|
||||
// purchase) or nothing does, as on a ladder a bundle's subscription vacated.
|
||||
type Paying struct {
|
||||
// ProductID is the tier it pays for.
|
||||
ProductID string
|
||||
Rank int32
|
||||
// GrantHolds is true when a grant holds the ladder's rung. A default
|
||||
// position is not a grant.
|
||||
GrantHolds bool
|
||||
// Suspended is true when its status is past_due, unpaid or paused.
|
||||
Suspended bool
|
||||
// Paused is true when its status is paused.
|
||||
Paused bool
|
||||
// ConsolePaused is true when its row records a pause and the console's
|
||||
// marker.
|
||||
ConsolePaused bool
|
||||
// MoveScheduled is true when a cancellation or a downgrade is scheduled
|
||||
// on it.
|
||||
MoveScheduled bool
|
||||
// OtherLadders is true when its product is a tier of another ladder too.
|
||||
OtherLadders bool
|
||||
}
|
||||
|
||||
// Target is the product a move would land on and the price it would use.
|
||||
@@ -110,6 +137,8 @@ const (
|
||||
OtherLadder Reason = "another ladder of the product forbids it"
|
||||
Suspended Reason = "the position is suspended"
|
||||
Paused Reason = "the position is paused"
|
||||
PaidHere Reason = "a subscription pays for the target on this ladder without holding it"
|
||||
PaysOnLadder Reason = "a subscription pays on the ladder without holding it"
|
||||
)
|
||||
|
||||
// Relation is the move relative to the current rung on the ladder. Offer sets
|
||||
@@ -162,6 +191,22 @@ func (p Position) rankOf(productID string) (int32, bool) {
|
||||
//
|
||||
// 1. The target is not a tier of the ladder: none.
|
||||
// 2. The organization holds a product that is not a tier of the ladder: none.
|
||||
//
|
||||
// Rule 3a sits ahead of rule 3. It applies when Paying is set and no
|
||||
// subscription backs the held position (!Subscribed), so a subscription that
|
||||
// holds the rung over another one keeps its own moves; it answers no Checkout
|
||||
// on any ladder a subscription pays on, because a Checkout there would start
|
||||
// a second subscription:
|
||||
//
|
||||
// - 3a.1 The target is the paying product: none, PaidHere.
|
||||
// - 3a.2 A grant holds, the paying subscription is not suspended, and the
|
||||
// target ranks at or above the current rung and above the paying product:
|
||||
// none, PaysOnLadder.
|
||||
// - 3a.3 The target is the current product: none, Current.
|
||||
// - 3a.4 Any other target: none, PaysOnLadder.
|
||||
//
|
||||
// The rules continue:
|
||||
//
|
||||
// 3. The target is the current product: none.
|
||||
// 4. The position is paused: none, with Paused.
|
||||
// 5. A subscription pays, no cancellation is scheduled, and the target is the
|
||||
@@ -212,6 +257,9 @@ func Offer(pos Position, t Target) Move {
|
||||
a.rel = Upgrade
|
||||
}
|
||||
}
|
||||
if m, ok := a.payingRules(); ok {
|
||||
return m
|
||||
}
|
||||
if t.ProductID == pos.Current {
|
||||
return Move{Reason: Current}
|
||||
}
|
||||
@@ -228,6 +276,24 @@ func Offer(pos Position, t Target) Move {
|
||||
return a.route()
|
||||
}
|
||||
|
||||
// payingRules applies rule 3a, which answers every target on a ladder a
|
||||
// subscription pays on without backing the held position.
|
||||
func (a ask) payingRules() (Move, bool) {
|
||||
p := a.Paying
|
||||
if p == nil || a.Subscribed {
|
||||
return Move{}, false
|
||||
}
|
||||
switch {
|
||||
case a.ProductID == p.ProductID:
|
||||
return Move{Reason: PaidHere}, true
|
||||
case p.GrantHolds && !p.Suspended && a.rank >= a.current && a.rank > p.Rank:
|
||||
return Move{Reason: PaysOnLadder}, true
|
||||
case a.ProductID == a.Current:
|
||||
return Move{Reason: Current}, true
|
||||
}
|
||||
return Move{Reason: PaysOnLadder}, true
|
||||
}
|
||||
|
||||
func (a ask) isHeld() bool {
|
||||
return a.Held != nil && a.Held.ProductID == a.ProductID
|
||||
}
|
||||
|
||||
@@ -203,6 +203,33 @@ func TestOffer(t *testing.T) {
|
||||
{name: "paused, the current tier", pos: paused(holds("plus", true)), target: target("plus"),
|
||||
reason: Current},
|
||||
|
||||
// A subscription that pays on the ladder without holding its rung.
|
||||
{name: "the paid tier", pos: payingOn(holds("basic", false), "plus", true), target: target("plus"),
|
||||
reason: PaidHere},
|
||||
{name: "a tier between a lower grant and the paid tier", pos: payingOn(holds("basic", false), "plus", true),
|
||||
target: target("standard"), reason: PaysOnLadder},
|
||||
{name: "a tier below the grant with a paying subscription", pos: payingOn(holds("plus", false), "basic", true),
|
||||
target: target("standard"), reason: PaysOnLadder},
|
||||
{name: "a tier above the grant with a paying subscription", pos: payingOn(holds("plus", false), "basic", true),
|
||||
target: target("pro"), reason: PaysOnLadder},
|
||||
{name: "the granted tier over a lower paid tier", pos: payingOn(holds("plus", false), "basic", true),
|
||||
target: target("plus"), reason: PaysOnLadder},
|
||||
{name: "a past-due paying subscription", pos: payingSuspended(payingOn(holds("plus", false), "basic", true), false),
|
||||
target: target("pro"), reason: PaysOnLadder},
|
||||
{name: "a paused paying subscription", pos: payingSuspended(payingOn(holds("plus", false), "basic", true), true),
|
||||
target: target("pro"), reason: PaysOnLadder},
|
||||
{name: "a bundle's vacated ladder", pos: payingOn(holds("", false), "plus", false), target: target("pro"),
|
||||
reason: PaysOnLadder},
|
||||
{name: "a bundle's vacated ladder, the paid tier", pos: payingOn(holds("", false), "plus", false),
|
||||
target: target("plus"), reason: PaidHere},
|
||||
{name: "the default holds the rung", pos: payingOn(holds("basic", false), "plus", false), target: target("pro"),
|
||||
reason: PaysOnLadder},
|
||||
{name: "a subscription that holds the rung over another one",
|
||||
pos: payingOn(holds("standard", true), "plus", false), target: target("pro"),
|
||||
kind: Switch, enabled: true, relation: Upgrade},
|
||||
{name: "a subscription that holds the rung over another one keeps its own current tier",
|
||||
pos: payingOn(holds("standard", true), "plus", false), target: target("standard"), reason: Current},
|
||||
|
||||
// Placement.
|
||||
{name: "a target that is not a tier of the ladder offers nothing", pos: holds("", false), target: target("elsewhere"),
|
||||
reason: NotOnLadder},
|
||||
@@ -214,6 +241,41 @@ func TestOffer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// payingOn adds a subscription that pays for the product on the ladder
|
||||
// without holding its rung to a position; grant says a grant holds the rung.
|
||||
func payingOn(p Position, product string, grant bool) Position {
|
||||
p.Paying = &Paying{ProductID: product, Rank: tierOf(product).Rank, GrantHolds: grant}
|
||||
return p
|
||||
}
|
||||
|
||||
// payingSuspended makes the paying subscription suspended, paused or not.
|
||||
func payingSuspended(p Position, paused bool) Position {
|
||||
paying := *p.Paying
|
||||
paying.Suspended, paying.Paused = true, paused
|
||||
p.Paying = &paying
|
||||
return p
|
||||
}
|
||||
|
||||
// TestOfferSellsNothingOnAPayingLadder checks that no target is sold through
|
||||
// Checkout, or offered at all, on a ladder a subscription cannot be switched
|
||||
// on: a suspended paying subscription, a vacated bundle ladder and a rung the
|
||||
// default holds.
|
||||
func TestOfferSellsNothingOnAPayingLadder(t *testing.T) {
|
||||
positions := map[string]Position{
|
||||
"past due": payingSuspended(payingOn(holds("plus", false), "basic", true), false),
|
||||
"paused": payingSuspended(payingOn(holds("plus", false), "basic", true), true),
|
||||
"a vacated ladder": payingOn(holds("", false), "plus", false),
|
||||
"the default": payingOn(holds("basic", false), "plus", false),
|
||||
}
|
||||
for name, pos := range positions {
|
||||
for _, tier := range ladderTiers {
|
||||
if got := Offer(pos, target(tier.ProductID)); got.Kind != None {
|
||||
t.Errorf("Offer(%s, %s) = %+v, want none", name, tier.ProductID, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// offerCase is one row of TestOffer: a position, a target and the move the
|
||||
// decision answers.
|
||||
type offerCase struct {
|
||||
|
||||
@@ -281,7 +281,11 @@ func (h *BillingCheckoutHandler) checkoutOffer(ctx context.Context, orgID string
|
||||
if err != nil {
|
||||
return plans.Move{}, fmt.Errorf("list tiers of ladder %s: %w", ladder.PlanLadderID, err)
|
||||
}
|
||||
pos, _, err := loader.onLadder(ctx, ladder.PlanLadderID, tiers, held[ladder.PlanLadderID])
|
||||
paying, err := loader.payingOn(ctx, orgID, ladder.PlanLadderID, held)
|
||||
if err != nil {
|
||||
return plans.Move{}, err
|
||||
}
|
||||
pos, _, err := loader.onLadder(ctx, ladder.PlanLadderID, tiers, held[ladder.PlanLadderID], paying)
|
||||
if err != nil {
|
||||
return plans.Move{}, fmt.Errorf("read the position on ladder %s: %w", ladder.PlanLadderID, err)
|
||||
}
|
||||
|
||||
@@ -334,6 +334,7 @@ func TestCheckoutRefusesWhenAReadFails(t *testing.T) {
|
||||
{"ListPricesByProduct", solo, wantPassed, failingBillingQ{Querier: realBilling, fail: "ListPricesByProduct"}, realEnts},
|
||||
{"GetDefaultPoolByOrgID", pro, wantRefused, realBilling, failingEntitlementsQ{Querier: realEnts, fail: "GetDefaultPoolByOrgID"}},
|
||||
{"ListLadderPositionsByPool", pro, wantRefused, realBilling, failingEntitlementsQ{Querier: realEnts, fail: "ListLadderPositionsByPool"}},
|
||||
{"ListSubscriptionRungsByOrg", pro, wantRefused, failingBillingQ{Querier: realBilling, fail: "ListSubscriptionRungsByOrg"}, realEnts},
|
||||
{"GetHeldGrantOnLadder", pro, wantRefused, realBilling, failingEntitlementsQ{Querier: realEnts, fail: "GetHeldGrantOnLadder"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
@@ -526,3 +527,174 @@ func checkSuspendedCheckout(t *testing.T, env suspendedCheckout, row suspendedCh
|
||||
t.Errorf("Checkout of a tier of another ladder: status %d body %q, want 503 billing setup in progress", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// paidUnderGrant is Wiki (Basic, Plus, Standard, Pro) and Duo (Plus, Duo Max),
|
||||
// one organization whose subscription to Plus a grant of Standard displaced
|
||||
// from both ladders, one per suspending status whose displaced subscription has it, and one that
|
||||
// holds the grant alone, each with a synced
|
||||
// customer and a Stripe backend that creates a Checkout session.
|
||||
type paidUnderGrant struct {
|
||||
h *BillingCheckoutHandler
|
||||
sm *scs.SessionManager
|
||||
mock *stripetest.MockBackend
|
||||
paying world.Org
|
||||
// suspended maps each suspending status to an organization whose
|
||||
// displaced subscription has it.
|
||||
suspended map[string]world.Org
|
||||
// trialing is an organization whose displaced subscription is trialing.
|
||||
trialing world.Org
|
||||
// endedItem is an organization whose subscription ended an item naming
|
||||
// the tier its grant holds on the Other ladder, and pays on another ladder.
|
||||
endedItem world.Org
|
||||
otherHigh world.Price
|
||||
granted world.Org
|
||||
pro world.Price
|
||||
duoMax world.Price
|
||||
}
|
||||
|
||||
func newPaidUnderGrant(t *testing.T) paidUnderGrant {
|
||||
t.Helper()
|
||||
db := dbtest.Open(t)
|
||||
w := world.New(t, db)
|
||||
priced := func(name string, cents int32) (world.Product, world.Price) {
|
||||
p := w.Product(name)
|
||||
price := w.Price(p, cents)
|
||||
w.MapPrice(price)
|
||||
return p, price
|
||||
}
|
||||
basic, _ := priced("Basic", 500)
|
||||
plus, _ := priced("Plus", 1000)
|
||||
standard, _ := priced("Standard", 2000)
|
||||
pro, proPrice := priced("Pro", 3000)
|
||||
duoMax, duoMaxPrice := priced("Duo Max", 4000)
|
||||
w.Ladder("Wiki", basic, plus, standard, pro)
|
||||
w.Ladder("Duo", plus, duoMax)
|
||||
orgType := w.OrgType("pg")
|
||||
owner := w.Person("Owner")
|
||||
|
||||
mono, _ := priced("Mono", 700)
|
||||
otherLow, otherLowPrice := priced("Other Low", 800)
|
||||
otherHigh, otherHighPrice := priced("Other High", 900)
|
||||
w.Ladder("Mono", mono)
|
||||
w.Ladder("Other", otherLow, otherHigh)
|
||||
env := paidUnderGrant{pro: proPrice, duoMax: duoMaxPrice, otherHigh: otherHighPrice}
|
||||
env.paying = w.Org("Paying", orgType, owner)
|
||||
sub := w.Subscribe(env.paying, world.SubOpts{PeriodEnd: noon(2099, time.January, 15)}, plus)
|
||||
w.Issue(env.paying, standard, world.IssueOpts{})
|
||||
w.MapAccount(world.Account{ID: sub.BillingAccountID, OrgID: env.paying.ID})
|
||||
env.suspended = make(map[string]world.Org)
|
||||
for _, status := range []string{"past_due", "unpaid", "paused"} {
|
||||
org := w.Org("Displaced "+status, orgType, owner)
|
||||
suspendedSub := w.Subscribe(org, world.SubOpts{PeriodEnd: noon(2099, time.January, 15)}, plus)
|
||||
w.Issue(org, standard, world.IssueOpts{})
|
||||
w.Suspend(suspendedSub, status)
|
||||
w.MapAccount(world.Account{ID: suspendedSub.BillingAccountID, OrgID: org.ID})
|
||||
env.suspended[status] = org
|
||||
}
|
||||
env.trialing = w.Org("Displaced trialing", orgType, owner)
|
||||
trialingSub := w.Subscribe(env.trialing, world.SubOpts{PeriodEnd: noon(2099, time.January, 15)}, plus)
|
||||
w.Issue(env.trialing, standard, world.IssueOpts{})
|
||||
if _, err := db.ExecContext(w.Ctx, `UPDATE core.subscriptions SET status = 'trialing' WHERE subscription_id = $1`, trialingSub.ID); err != nil {
|
||||
t.Fatalf("trialing subscription: %v", err)
|
||||
}
|
||||
w.MapAccount(world.Account{ID: trialingSub.BillingAccountID, OrgID: env.trialing.ID})
|
||||
// The subscription's current item is Mono's; its ended item names Other
|
||||
// Low, which a grant holds. The ended item is the one the reconcile's
|
||||
// endReplacedItems ends.
|
||||
env.endedItem = w.Org("Ended item", orgType, owner)
|
||||
endedSub := w.Subscribe(env.endedItem, world.SubOpts{PeriodEnd: noon(2099, time.January, 15)}, mono)
|
||||
w.Issue(env.endedItem, otherLow, world.IssueOpts{})
|
||||
oldItem, err := billing.New(db).CreateSubscriptionItem(w.Ctx, billing.CreateSubscriptionItemParams{
|
||||
SubscriptionID: endedSub.ID, ProductID: otherLow.ID, PriceID: otherLowPrice.ID, Quantity: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ended item: %v", err)
|
||||
}
|
||||
if _, err := billing.New(db).EndSubscriptionItem(w.Ctx, oldItem.SubscriptionItemID); err != nil {
|
||||
t.Fatalf("end the item: %v", err)
|
||||
}
|
||||
w.MapAccount(world.Account{ID: endedSub.BillingAccountID, OrgID: env.endedItem.ID})
|
||||
env.granted = w.Org("Granted", orgType, owner)
|
||||
w.Issue(env.granted, standard, world.IssueOpts{})
|
||||
w.MapAccount(w.BillingAccount(env.granted, "Granted"))
|
||||
|
||||
env.mock = &stripetest.MockBackend{Handle: func(c stripetest.Call) (any, error, bool) {
|
||||
if c.Method == http.MethodPost && c.Path == "/v1/checkout/sessions" {
|
||||
return &stripe.CheckoutSession{ID: "cs_test_1", URL: "https://checkout.example.test/c/pay/cs_test_1"}, nil, true
|
||||
}
|
||||
return nil, nil, false
|
||||
}}
|
||||
t.Cleanup(stripetest.Install(env.mock))
|
||||
env.sm = authtest.NewSessionManager()
|
||||
env.h = &BillingCheckoutHandler{
|
||||
Database: db, BillingQ: billing.New(db), EntitlementsQ: entitlements.New(db),
|
||||
AuthConfig: &auth.Config{SessionManager: env.sm},
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
BaseURL: "https://example.test", StripeMode: "test",
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// post posts the price for the organization and returns the response with the
|
||||
// number of Stripe calls the request made.
|
||||
func (e paidUnderGrant) post(t *testing.T, org world.Org, price world.Price) (*httptest.ResponseRecorder, int) {
|
||||
t.Helper()
|
||||
before := len(e.mock.Calls)
|
||||
sctx := authtest.SignIn(t, e.sm, auth.SignIn{OrgID: org.ID})
|
||||
rec := authtest.Post(t, sctx, e.h.HandleCheckout, "/billing/checkout", url.Values{"price_id": {price.ID}})
|
||||
return rec, len(e.mock.Calls) - before
|
||||
}
|
||||
|
||||
// TestCheckoutRefusesALadderASubscriptionPaysFor: a subscription that pays for
|
||||
// a tier of a ladder without holding its rung is the organization's plan
|
||||
// there, so Checkout of a tier above the grant, or of a tier of the ladder the
|
||||
// subscription vacated, would start a second subscription: 422 with the plan
|
||||
// sentence and no Stripe call.
|
||||
func TestCheckoutRefusesALadderASubscriptionPaysFor(t *testing.T) {
|
||||
env := newPaidUnderGrant(t)
|
||||
const refusal = "That plan is no longer available. Refresh the page and choose a current plan."
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
org world.Org
|
||||
price world.Price
|
||||
}{
|
||||
{"above the grant", env.paying, env.pro},
|
||||
{"the vacated ladder of a bundle", env.paying, env.duoMax},
|
||||
{"above the grant over a past-due subscription", env.suspended["past_due"], env.pro},
|
||||
{"above the grant over an unpaid subscription", env.suspended["unpaid"], env.pro},
|
||||
{"above the grant over a trialing subscription", env.trialing, env.pro},
|
||||
{"above the grant over a paused subscription", env.suspended["paused"], env.pro},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
rec, calls := env.post(t, row.org, row.price)
|
||||
body := strings.TrimSpace(rec.Body.String())
|
||||
if rec.Code != http.StatusUnprocessableEntity || body != refusal || calls != 0 {
|
||||
t.Errorf("%s: checkout answered %d, want 422, with %d Stripe calls, body %q", row.name, rec.Code, calls, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckoutAboveAGrantWithNoSubscription is the controls: the same grant
|
||||
// with no subscription behind it still sells the tier above, and sells the
|
||||
// bundle's other ladder, so the refusals above come from the offer and not
|
||||
// from a price the mapping refuses.
|
||||
func TestCheckoutAboveAGrantWithNoSubscription(t *testing.T) {
|
||||
env := newPaidUnderGrant(t)
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
org world.Org
|
||||
price world.Price
|
||||
}{
|
||||
{"Pro", env.granted, env.pro},
|
||||
{"Duo Max", env.granted, env.duoMax},
|
||||
{"a ladder only an ended item names", env.endedItem, env.otherHigh},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
rec, calls := env.post(t, row.org, row.price)
|
||||
if rec.Code != http.StatusSeeOther || calls != 1 {
|
||||
t.Errorf("checkout of %s above a grant with no subscription answered %d with %d Stripe calls, want 303 and 1", row.name, rec.Code, calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,6 +286,7 @@ func (h *MemberProductsHandler) GetPlans(w http.ResponseWriter, r *http.Request)
|
||||
// can offer a move the endpoints refuse (offer-enforcement D7).
|
||||
func (h *MemberProductsHandler) buildPlansData(ctx context.Context, orgID string) PlansData {
|
||||
b := plansBuild{
|
||||
orgID: orgID,
|
||||
loader: newPositionLoader(h.BillingQ, h.EntitlementsQ, h.Database, time.Now),
|
||||
// Resolved once for every tier's feature lines: the catalog must agree
|
||||
// with the entitlement view on resource-key display names (raw keys like
|
||||
@@ -352,6 +353,8 @@ type plansBuild struct {
|
||||
planByID map[string]billing.Product
|
||||
// positions is the position the organization holds on each ladder.
|
||||
positions map[string]entitlements.ListLadderPositionsByPoolRow
|
||||
// orgID is the organization the page is built for.
|
||||
orgID string
|
||||
// moves collects the decision for every drawn tier by product, for the
|
||||
// combination across the ladders a product is a tier of.
|
||||
moves map[string][]plans.Move
|
||||
@@ -365,7 +368,11 @@ func (h *MemberProductsHandler) buildLadder(ctx context.Context, b *plansBuild,
|
||||
return LadderViewModel{}, fmt.Errorf("list tiers of ladder %s: %w", ladder.Name, err)
|
||||
}
|
||||
held := b.positions[ladder.PlanLadderID]
|
||||
pos, facts, err := b.loader.onLadder(ctx, ladder.PlanLadderID, tiers, held)
|
||||
paying, err := b.loader.payingOn(ctx, b.orgID, ladder.PlanLadderID, b.positions)
|
||||
if err != nil {
|
||||
return LadderViewModel{}, fmt.Errorf("read the paying subscription of ladder %s: %w", ladder.Name, err)
|
||||
}
|
||||
pos, facts, err := b.loader.onLadder(ctx, ladder.PlanLadderID, tiers, held, paying)
|
||||
if err != nil {
|
||||
return LadderViewModel{}, fmt.Errorf("read the position on ladder %s: %w", ladder.Name, err)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
package server
|
||||
|
||||
// The Products page's plans region, characterized state by state: for each of
|
||||
// twenty-two organizations, the view model buildPlansData returns, cut to the
|
||||
// twenty-three organizations, the view model buildPlansData returns, cut to the
|
||||
// fixture's ladders and without the billing paragraph and the preview, and the
|
||||
// member_plans.html render of that data, compared byte for byte with the files
|
||||
// under testdata/member_plans. A change to anything else a member sees on the
|
||||
@@ -93,7 +93,7 @@ func registerGoldenKeys(t testing.TB, w *world.World) {
|
||||
}
|
||||
}
|
||||
|
||||
// plansGoldenFixture is the three ladders and twenty-two organizations the
|
||||
// plansGoldenFixture is the three ladders and twenty-three organizations the
|
||||
// plans golden reads, built once. Every date a state prints is fixed, far
|
||||
// enough ahead that the clock never reaches it, and falls at 12:00 UTC.
|
||||
type plansGoldenFixture struct {
|
||||
@@ -262,6 +262,13 @@ func newPlansGoldenFixture(ctx context.Context, t *testing.T, w *world.World) *p
|
||||
|
||||
w.Subscribe(org("paid-basic-plus-on-two-ladders"), world.SubOpts{PeriodEnd: periodEnd}, basic)
|
||||
|
||||
// A grant of Pro displaces the subscription to Plus, which pays on both of
|
||||
// its ladders without holding either.
|
||||
o = org("paid-plus-under-granted-pro")
|
||||
sub = w.Subscribe(o, world.SubOpts{PeriodEnd: periodEnd}, plus)
|
||||
w.Issue(o, pro, world.IssueOpts{})
|
||||
w.PauseCollection(sub)
|
||||
|
||||
w.Issue(org("retired-current-rung"), gone, world.IssueOpts{})
|
||||
w.Retire(gone)
|
||||
return f
|
||||
|
||||
@@ -79,6 +79,13 @@ func (q failingBillingQ) ListPricesByProduct(ctx context.Context, productID stri
|
||||
return q.Querier.ListPricesByProduct(ctx, productID)
|
||||
}
|
||||
|
||||
func (q failingBillingQ) ListSubscriptionRungsByOrg(ctx context.Context, orgID string) ([]billing.ListSubscriptionRungsByOrgRow, error) {
|
||||
if q.fail == "ListSubscriptionRungsByOrg" {
|
||||
return nil, errReadFailed
|
||||
}
|
||||
return q.Querier.ListSubscriptionRungsByOrg(ctx, orgID)
|
||||
}
|
||||
|
||||
func (q failingBillingQ) ListPlanLadders(ctx context.Context) ([]billing.PlanLadder, error) {
|
||||
if q.fail == "ListPlanLadders" {
|
||||
return nil, errReadFailed
|
||||
@@ -219,6 +226,7 @@ func TestPlansShowTheLoadErrorWhenAReadFails(t *testing.T) {
|
||||
"GetHeldGrantOnLadder",
|
||||
"PredictCancellationOutcome",
|
||||
"ListPricesByProduct",
|
||||
"ListSubscriptionRungsByOrg",
|
||||
} {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
data := w.handlerFailing(method).buildPlansData(w.ctx, org)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package server
|
||||
|
||||
// The Products page asks which subscriptions pay for a tier of a ladder
|
||||
// without holding its rung once per request, however many ladders it draws
|
||||
// (grant-pauses-subscription-billing D9), counted as
|
||||
// TestOperatorPagesReadAFixedNumberOfTimes counts the operator pages.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"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/testkit/dbtest"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world"
|
||||
)
|
||||
|
||||
func TestProductsPageReadsPayingOnce(t *testing.T) {
|
||||
counted, stmts := dbtest.Counted(t)
|
||||
w := world.New(t, dbtest.Open(t))
|
||||
h, err := NewMemberProductsHandler(MemberProductsConfig{
|
||||
BillingQ: billing.New(counted), EntitlementsQ: entitlements.New(counted),
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Database: counted, StripeMode: "test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("member handler: %v", err)
|
||||
}
|
||||
owner := w.Person("Owner")
|
||||
org := w.Org("Reader", w.OrgType("rc"), owner)
|
||||
w.Issue(org, w.Product("Granted"), world.IssueOpts{})
|
||||
|
||||
// The ladders the fixture adds hold no tier a grant holds against a
|
||||
// paying subscription.
|
||||
for _, grown := range []int{1, 4} {
|
||||
for n := 0; n < grown; n++ {
|
||||
name := fmt.Sprintf("Counted %d of %d", n, grown)
|
||||
w.Ladder(name, w.Product(name))
|
||||
}
|
||||
stmts.Reset()
|
||||
if data := h.buildPlansData(context.Background(), org.ID); data.Error != "" {
|
||||
t.Fatalf("the page failed to load: %s", data.Error)
|
||||
}
|
||||
if got := stmts.ByName()["ListSubscriptionRungsByOrg"]; got != 1 {
|
||||
t.Errorf("the Products page ran %d statements, want %d", got, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,6 +346,16 @@ func (q offerFailingPrices) ListPricesByProduct(context.Context, string) ([]bill
|
||||
return nil, errOfferRead
|
||||
}
|
||||
|
||||
// offerFailingRungs fails the billing querier's read of the subscriptions that
|
||||
// pay on a ladder.
|
||||
type offerFailingRungs struct {
|
||||
billing.Querier
|
||||
}
|
||||
|
||||
func (q offerFailingRungs) ListSubscriptionRungsByOrg(context.Context, string) ([]billing.ListSubscriptionRungsByOrgRow, error) {
|
||||
return nil, errOfferRead
|
||||
}
|
||||
|
||||
// TestSwitchRefusesWhenAReadFails pins that a read the offer needs which fails
|
||||
// answers the move-failed sentence and adds no Stripe call, where the held-rung
|
||||
// check let the request through on every read error but one. Each subtest has a
|
||||
@@ -372,6 +382,7 @@ func TestSwitchRefusesWhenAReadFails(t *testing.T) {
|
||||
{"ListLadderPositionsByPool", handler(offerFailingEnts{realEnts, "ListLadderPositionsByPool"}, realBilling)},
|
||||
{"GetHeldGrantOnLadder", handler(offerFailingEnts{realEnts, "GetHeldGrantOnLadder"}, realBilling)},
|
||||
{"ListPricesByProduct", handler(realEnts, offerFailingPrices{realBilling})},
|
||||
{"ListSubscriptionRungsByOrg", handler(realEnts, offerFailingRungs{realBilling})},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.fail, func(t *testing.T) {
|
||||
|
||||
@@ -32,6 +32,9 @@ type positionLoader struct {
|
||||
stripeQ internalstripe.Querier
|
||||
now func() time.Time
|
||||
predictions map[string]*cancelPrediction
|
||||
// paying is payingByLadder over the organization's live subscriptions,
|
||||
// read once per request and nil until then.
|
||||
paying map[string]plans.Paying
|
||||
}
|
||||
|
||||
// newPositionLoader builds a loader over the billing and entitlements
|
||||
@@ -72,6 +75,89 @@ func (l *positionLoader) positions(ctx context.Context, orgID string) (map[strin
|
||||
return entitlements.PositionsByLadder(rows), nil
|
||||
}
|
||||
|
||||
// payingOn is the subscription that pays for a tier of the ladder without
|
||||
// holding its rung, nil when none does. The first call's orgID and held decide
|
||||
// the map; a loader serves one organization. The organization's subscriptions are
|
||||
// read once per request with ListSubscriptionRungsByOrg and cached, as the
|
||||
// cancel predictions are, however many ladders ask; the choice from them is
|
||||
// payingByLadder's. held are the organization's positions by ladder
|
||||
// (positions), which a request does not change.
|
||||
func (l *positionLoader) payingOn(ctx context.Context, orgID, ladderID string, held map[string]entitlements.ListLadderPositionsByPoolRow) (*plans.Paying, error) {
|
||||
if l.paying == nil {
|
||||
rows, err := l.billing.ListSubscriptionRungsByOrg(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read the subscriptions of org %s that pay on a ladder: %w", orgID, err)
|
||||
}
|
||||
l.paying = payingByLadder(rows, held)
|
||||
}
|
||||
p, ok := l.paying[ladderID]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// payingByLadder keys, by plan ladder, the subscription that pays for a tier
|
||||
// of the ladder without holding its rung (entitlements.PayingSubscription
|
||||
// chooses it), reading no row. rows are the rows of ListSubscriptionRungsByOrg
|
||||
// and held the organization's positions by ladder. A subscription's row on the
|
||||
// ladder that names the lowest product is the tier it pays for.
|
||||
func payingByLadder(rows []billing.ListSubscriptionRungsByOrgRow, held map[string]entitlements.ListLadderPositionsByPoolRow) map[string]plans.Paying {
|
||||
byLadder := make(map[string][]billing.ListSubscriptionRungsByOrgRow)
|
||||
ladderSets := make(map[[2]string]map[string]bool)
|
||||
for _, r := range rows {
|
||||
byLadder[r.PlanLadderID] = append(byLadder[r.PlanLadderID], r)
|
||||
key := [2]string{r.SubscriptionID, r.ProductID}
|
||||
if ladderSets[key] == nil {
|
||||
ladderSets[key] = make(map[string]bool)
|
||||
}
|
||||
ladderSets[key][r.PlanLadderID] = true
|
||||
}
|
||||
out := make(map[string]plans.Paying, len(byLadder))
|
||||
for ladderID, ladderRows := range byLadder {
|
||||
ids := make([]string, len(ladderRows))
|
||||
for i, r := range ladderRows {
|
||||
ids[i] = r.SubscriptionID
|
||||
}
|
||||
id, ok := entitlements.PayingSubscription(held[ladderID], ids)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := firstRowOf(ladderRows, id)
|
||||
out[ladderID] = payingFrom(row, held[ladderID], len(ladderSets[[2]string{id, row.ProductID}]) > 1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// firstRowOf returns the row of the subscription with the lowest product id.
|
||||
func firstRowOf(rows []billing.ListSubscriptionRungsByOrgRow, subscriptionID string) billing.ListSubscriptionRungsByOrgRow {
|
||||
var first billing.ListSubscriptionRungsByOrgRow
|
||||
found := false
|
||||
for _, r := range rows {
|
||||
if r.SubscriptionID != subscriptionID || (found && r.ProductID >= first.ProductID) {
|
||||
continue
|
||||
}
|
||||
first, found = r, true
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
// payingFrom states the facts of a paying subscription from its row and the
|
||||
// ladder's held position. GrantHolds is the position's grant_id being set
|
||||
// while it is not default-sourced: a default position is a grant row too.
|
||||
func payingFrom(r billing.ListSubscriptionRungsByOrgRow, held entitlements.ListLadderPositionsByPoolRow, otherLadders bool) plans.Paying {
|
||||
return plans.Paying{
|
||||
ProductID: r.ProductID,
|
||||
Rank: r.Rank,
|
||||
GrantHolds: held.GrantID.Valid && !held.DefaultSourced,
|
||||
Suspended: r.Status == "past_due" || r.Status == "unpaid" || r.Status == "paused",
|
||||
Paused: r.Status == "paused",
|
||||
ConsolePaused: r.PauseCollectionBehavior.Valid && r.PauseCollectionMarked,
|
||||
MoveScheduled: r.CancelAtPeriodEnd || r.CancellationAt.Valid || r.DowngradeAt.Valid,
|
||||
OtherLadders: otherLadders,
|
||||
}
|
||||
}
|
||||
|
||||
// ladderFacts are what the Products page draws beside a position: the row
|
||||
// behind the current rung, the date an armed cancellation takes effect, the
|
||||
// tier a scheduled downgrade moves to and when, and the cancel prediction.
|
||||
@@ -110,8 +196,9 @@ func suspensionOf(positionStatus, subscriptionStatus string) string {
|
||||
}
|
||||
|
||||
// onLadder reads the organization's position on one ladder, given the row of
|
||||
// the rung it holds there (the zero row when it holds none) and the ladder's
|
||||
// tiers.
|
||||
// the rung it holds there (the zero row when it holds none), the ladder's
|
||||
// tiers and the subscription that pays on the ladder without holding its rung
|
||||
// (payingOn), which is pos.Paying.
|
||||
//
|
||||
// Only a subscription can be switched or cancelled
|
||||
// (stripe-subscription-management), so a rung is paid for, for routing its
|
||||
@@ -130,8 +217,8 @@ func suspensionOf(positionStatus, subscriptionStatus string) string {
|
||||
// paused only when its subscription's status is paused. A suspended
|
||||
// position's scheduled downgrade whose date is not after now has already been
|
||||
// applied by Stripe, so it is left unset.
|
||||
func (l *positionLoader) onLadder(ctx context.Context, ladderID string, tiers []billing.ListTiersByLadderWithProductsRow, held entitlements.ListLadderPositionsByPoolRow) (plans.Position, ladderFacts, error) {
|
||||
pos := plans.Position{Current: held.ProductID}
|
||||
func (l *positionLoader) onLadder(ctx context.Context, ladderID string, tiers []billing.ListTiersByLadderWithProductsRow, held entitlements.ListLadderPositionsByPoolRow, paying *plans.Paying) (plans.Position, ladderFacts, error) {
|
||||
pos := plans.Position{Current: held.ProductID, Paying: paying}
|
||||
for _, t := range tiers {
|
||||
pos.Tiers = append(pos.Tiers, plans.Tier{ProductID: t.ProductID, Rank: t.Rank})
|
||||
}
|
||||
@@ -334,6 +421,17 @@ func (h *MemberProductsHandler) switchOfferFor(ctx context.Context, orgID, poste
|
||||
// order is the same); and anything else is an unavailable plan. The member
|
||||
// gate and a malformed or unknown price id answer before all of them. A read
|
||||
// that fails is returned.
|
||||
//
|
||||
// On a ladder a subscription pays on without backing the held position
|
||||
// (pos.Paying set, pos.Subscribed false, the condition of the offer decision's
|
||||
// rule 3a) the refusal comes from the paying subscription, ahead of the
|
||||
// !pos.Subscribed case: a suspended paying
|
||||
// subscription answers fulfillment.ErrSubscriptionSuspended, or
|
||||
// fulfillment.ErrSubscriptionPaused when it is paused, and any other refusal
|
||||
// is an unavailable plan, because "no active subscription" would be false
|
||||
// there. The !pos.Subscribed case answers only a ladder no subscription pays
|
||||
// on, and a subscription that holds the rung over a paying one keeps the
|
||||
// refusals above.
|
||||
func (h *MemberProductsHandler) switchOffer(ctx context.Context, orgID, ladderID string, price billing.Price) error {
|
||||
if err := h.switchTargetOpenToMembers(ctx, price.PriceID); err != nil {
|
||||
return err
|
||||
@@ -347,7 +445,11 @@ func (h *MemberProductsHandler) switchOffer(ctx context.Context, orgID, ladderID
|
||||
if err != nil {
|
||||
return fmt.Errorf("switch: %w", err)
|
||||
}
|
||||
pos, _, err := loader.onLadder(ctx, ladderID, tiers, held[ladderID])
|
||||
paying, err := loader.payingOn(ctx, orgID, ladderID, held)
|
||||
if err != nil {
|
||||
return fmt.Errorf("switch: %w", err)
|
||||
}
|
||||
pos, _, err := loader.onLadder(ctx, ladderID, tiers, held[ladderID], paying)
|
||||
if err != nil {
|
||||
return fmt.Errorf("switch: read the position on ladder %s: %w", ladderID, err)
|
||||
}
|
||||
@@ -370,6 +472,8 @@ func (h *MemberProductsHandler) switchOffer(ctx context.Context, orgID, ladderID
|
||||
return fulfillment.ErrSubscriptionPaused
|
||||
}
|
||||
return fulfillment.ErrSubscriptionSuspended
|
||||
case pos.Paying != nil && !pos.Subscribed:
|
||||
return h.refusePayingLadder(pos, ladderID, price.PriceID, move.Reason)
|
||||
case !pos.Subscribed:
|
||||
return fulfillment.ErrNoActivePaidSubscription
|
||||
case move.Reason == plans.Current:
|
||||
@@ -379,3 +483,17 @@ func (h *MemberProductsHandler) switchOffer(ctx context.Context, orgID, ladderID
|
||||
slog.String("price_id", price.PriceID), slog.String("ladder_id", ladderID), slog.String("reason", string(move.Reason)))
|
||||
return errPlanNotOffered
|
||||
}
|
||||
|
||||
// refusePayingLadder maps the refusal of a switch on a ladder a subscription
|
||||
// pays on without backing the held position (switchOffer's doc comment).
|
||||
func (h *MemberProductsHandler) refusePayingLadder(pos plans.Position, ladderID, priceID string, reason plans.Reason) error {
|
||||
h.Logger.Info("switch refused: a subscription pays on the ladder without holding it",
|
||||
slog.String("price_id", priceID), slog.String("ladder_id", ladderID), slog.String("reason", string(reason)))
|
||||
switch {
|
||||
case pos.Paying.Paused:
|
||||
return fulfillment.ErrSubscriptionPaused
|
||||
case pos.Paying.Suspended:
|
||||
return fulfillment.ErrSubscriptionSuspended
|
||||
}
|
||||
return errPlanNotOffered
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"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/plans"
|
||||
)
|
||||
|
||||
// The subscriptions, products and ladders of TestPayingByLadder. Their ids
|
||||
// sort as their names do.
|
||||
const (
|
||||
subA = "00000000-0000-0000-0000-00000000000a"
|
||||
subB = "00000000-0000-0000-0000-00000000000b"
|
||||
plusID = "plus"
|
||||
ladderL1 = "ladder-1"
|
||||
ladderL2 = "ladder-2"
|
||||
)
|
||||
|
||||
// rung is a row of ListSubscriptionRungsByOrg: subscription sub pays for plus
|
||||
// on ladder, active.
|
||||
func rung(sub, ladder string, edit func(*billing.ListSubscriptionRungsByOrgRow)) billing.ListSubscriptionRungsByOrgRow {
|
||||
r := billing.ListSubscriptionRungsByOrgRow{
|
||||
SubscriptionID: sub, Status: "active", ProductID: plusID, PlanLadderID: ladder, Rank: 2,
|
||||
}
|
||||
if edit != nil {
|
||||
edit(&r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// granted is the row of a ladder a grant holds; heldBy of one a subscription
|
||||
// holds.
|
||||
func granted(defaultSourced bool) entitlements.ListLadderPositionsByPoolRow {
|
||||
return entitlements.ListLadderPositionsByPoolRow{
|
||||
GrantID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, DefaultSourced: defaultSourced,
|
||||
}
|
||||
}
|
||||
|
||||
func heldBy(sub string) entitlements.ListLadderPositionsByPoolRow {
|
||||
return entitlements.ListLadderPositionsByPoolRow{
|
||||
SubscriptionID: uuid.NullUUID{UUID: uuid.MustParse(sub), Valid: true},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayingByLadder(t *testing.T) {
|
||||
grantHolds := map[string]entitlements.ListLadderPositionsByPoolRow{ladderL1: granted(false)}
|
||||
withEdit := func(edit func(*billing.ListSubscriptionRungsByOrgRow)) []billing.ListSubscriptionRungsByOrgRow {
|
||||
return []billing.ListSubscriptionRungsByOrgRow{rung(subA, ladderL1, edit)}
|
||||
}
|
||||
paying := func(edit func(*plans.Paying)) map[string]plans.Paying {
|
||||
p := plans.Paying{ProductID: plusID, Rank: 2, GrantHolds: true}
|
||||
if edit != nil {
|
||||
edit(&p)
|
||||
}
|
||||
return map[string]plans.Paying{ladderL1: p}
|
||||
}
|
||||
void := sql.NullString{String: "void", Valid: true}
|
||||
later := sql.NullTime{Time: time.Date(2099, 1, 15, 12, 0, 0, 0, time.UTC), Valid: true}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rows []billing.ListSubscriptionRungsByOrgRow
|
||||
held map[string]entitlements.ListLadderPositionsByPoolRow
|
||||
want map[string]plans.Paying
|
||||
}{
|
||||
{"the holding subscription", withEdit(nil),
|
||||
map[string]entitlements.ListLadderPositionsByPoolRow{ladderL1: heldBy(subA)}, map[string]plans.Paying{}},
|
||||
{"two subscriptions on one ladder", []billing.ListSubscriptionRungsByOrgRow{
|
||||
rung(subB, ladderL1, func(r *billing.ListSubscriptionRungsByOrgRow) { r.Status = "past_due" }),
|
||||
rung(subA, ladderL1, nil)}, grantHolds, paying(nil)},
|
||||
{"a bundle", []billing.ListSubscriptionRungsByOrgRow{rung(subA, ladderL1, nil), rung(subA, ladderL2, nil)},
|
||||
map[string]entitlements.ListLadderPositionsByPoolRow{ladderL1: granted(false), ladderL2: granted(false)},
|
||||
map[string]plans.Paying{
|
||||
ladderL1: {ProductID: plusID, Rank: 2, GrantHolds: true, OtherLadders: true},
|
||||
ladderL2: {ProductID: plusID, Rank: 2, GrantHolds: true, OtherLadders: true},
|
||||
}},
|
||||
{"the default holds the rung", withEdit(nil),
|
||||
map[string]entitlements.ListLadderPositionsByPoolRow{ladderL1: granted(true)},
|
||||
paying(func(p *plans.Paying) { p.GrantHolds = false })},
|
||||
{"nothing holds the rung", withEdit(nil), nil, paying(func(p *plans.Paying) { p.GrantHolds = false })},
|
||||
{"the console's pause recorded", withEdit(func(r *billing.ListSubscriptionRungsByOrgRow) {
|
||||
r.PauseCollectionBehavior, r.PauseCollectionMarked = void, true
|
||||
}), grantHolds, paying(func(p *plans.Paying) { p.ConsolePaused = true })},
|
||||
{"a pause made in the Dashboard", withEdit(func(r *billing.ListSubscriptionRungsByOrgRow) {
|
||||
r.PauseCollectionBehavior = void
|
||||
}), grantHolds, paying(nil)},
|
||||
{"a marker with no pause", withEdit(func(r *billing.ListSubscriptionRungsByOrgRow) {
|
||||
r.PauseCollectionMarked = true
|
||||
}), grantHolds, paying(nil)},
|
||||
{"a cancellation scheduled", withEdit(func(r *billing.ListSubscriptionRungsByOrgRow) {
|
||||
r.CancelAtPeriodEnd = true
|
||||
}), grantHolds, paying(func(p *plans.Paying) { p.MoveScheduled = true })},
|
||||
{"two tiers of one subscription on a ladder", []billing.ListSubscriptionRungsByOrgRow{
|
||||
rung(subA, ladderL1, func(r *billing.ListSubscriptionRungsByOrgRow) { r.ProductID, r.Rank = "pro", 4 }),
|
||||
rung(subA, ladderL1, nil)}, grantHolds, paying(nil)},
|
||||
{"unpaid", withEdit(func(r *billing.ListSubscriptionRungsByOrgRow) { r.Status = "unpaid" }),
|
||||
grantHolds, paying(func(p *plans.Paying) { p.Suspended = true })},
|
||||
{"a downgrade scheduled", withEdit(func(r *billing.ListSubscriptionRungsByOrgRow) {
|
||||
r.DowngradeAt = later
|
||||
}), grantHolds, paying(func(p *plans.Paying) { p.MoveScheduled = true })},
|
||||
{"a cancellation row scheduled", withEdit(func(r *billing.ListSubscriptionRungsByOrgRow) {
|
||||
r.CancellationAt = later
|
||||
}), grantHolds, paying(func(p *plans.Paying) { p.MoveScheduled = true })},
|
||||
{"past due", withEdit(func(r *billing.ListSubscriptionRungsByOrgRow) { r.Status = "past_due" }),
|
||||
grantHolds, paying(func(p *plans.Paying) { p.Suspended = true })},
|
||||
{"paused", withEdit(func(r *billing.ListSubscriptionRungsByOrgRow) { r.Status = "paused" }),
|
||||
grantHolds, paying(func(p *plans.Paying) { p.Suspended, p.Paused = true, true })},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// The rows arrive in the order the query gives them, and the
|
||||
// choice does not depend on it.
|
||||
for _, rows := range [][]billing.ListSubscriptionRungsByOrgRow{tc.rows, reversed(tc.rows)} {
|
||||
if got := payingByLadder(rows, tc.held); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("payingByLadder(%s) = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func reversed(rows []billing.ListSubscriptionRungsByOrgRow) []billing.ListSubscriptionRungsByOrgRow {
|
||||
out := slices.Clone(rows)
|
||||
slices.Reverse(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="border-start border-3 ps-3 mb-4 border-primary">
|
||||
<h3 class="fw-bold mb-3 h6">
|
||||
Wiki
|
||||
|
||||
</h3>
|
||||
<div class="row row-cols-1 row-cols-md-3 g-3">
|
||||
|
||||
<div class="col">
|
||||
<div class="card h-100">
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h4 class="card-title mb-0 h6">Free</h4>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<p class="text-muted small mb-2">Included</p>
|
||||
|
||||
|
||||
<ul class="list-unstyled small mb-3 flex-grow-1">
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<div class="card h-100">
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h4 class="card-title mb-0 h6">Basic</h4>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<p class="text-muted small mb-2">$5.00/month</p>
|
||||
|
||||
|
||||
<ul class="list-unstyled small mb-3 flex-grow-1">
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<div class="card h-100">
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h4 class="card-title mb-0 h6">Plus</h4>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<p class="text-muted small mb-2">$10.00/month</p>
|
||||
|
||||
|
||||
<ul class="list-unstyled small mb-3 flex-grow-1">
|
||||
|
||||
<li class="mb-1">· 1 Plan Site</li>
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<div class="card h-100">
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h4 class="card-title mb-0 h6">Standard</h4>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<p class="text-muted small mb-2">$20.00/month</p>
|
||||
|
||||
|
||||
<ul class="list-unstyled small mb-3 flex-grow-1">
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<div class="card h-100 border-primary">
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h4 class="card-title mb-0 h6">Pro</h4>
|
||||
<span class="badge text-bg-success">Current plan</span>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<ul class="list-unstyled small mb-3 flex-grow-1">
|
||||
|
||||
<li class="mb-1">· 10 Plan Sites</li>
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-start border-3 ps-3 mb-4 border-secondary-subtle">
|
||||
<h3 class="fw-bold mb-3 h6 text-muted">
|
||||
Duo
|
||||
|
||||
</h3>
|
||||
<div class="row row-cols-1 row-cols-md-3 g-3">
|
||||
|
||||
<div class="col">
|
||||
<div class="card h-100">
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h4 class="card-title mb-0 h6">Plus</h4>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<p class="text-muted small mb-2">$10.00/month</p>
|
||||
|
||||
|
||||
<ul class="list-unstyled small mb-3 flex-grow-1">
|
||||
|
||||
<li class="mb-1">· 1 Plan Site</li>
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<div class="card h-100">
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h4 class="card-title mb-0 h6">Duo Max</h4>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<p class="text-muted small mb-2">$40.00/month</p>
|
||||
|
||||
|
||||
<ul class="list-unstyled small mb-3 flex-grow-1">
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-start border-3 ps-3 mb-4 border-secondary-subtle">
|
||||
<h3 class="fw-bold mb-3 h6 text-muted">
|
||||
Prices
|
||||
|
||||
</h3>
|
||||
<div class="row row-cols-1 row-cols-md-3 g-3">
|
||||
|
||||
<div class="col">
|
||||
<div class="card h-100">
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h4 class="card-title mb-0 h6">Entry</h4>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<p class="text-muted small mb-2">$5.00/month</p>
|
||||
|
||||
|
||||
<ul class="list-unstyled small mb-3 flex-grow-1">
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
|
||||
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Entry>"}'
|
||||
hx-target="#checkout-error-<ladder:Prices>-<product:Entry>" hx-swap="innerHTML">Subscribe</button>
|
||||
<div id="checkout-error-<ladder:Prices>-<product:Entry>" class="small text-danger mt-1"></div>
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<div class="card h-100">
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h4 class="card-title mb-0 h6">Once</h4>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<ul class="list-unstyled small mb-3 flex-grow-1">
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Once>">Subscribe</button></span>
|
||||
<small id="move-disabled-<ladder:Prices>-<product:Once>" class="text-muted d-block mt-1">Not available for purchase yet</small>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<div class="card h-100">
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h4 class="card-title mb-0 h6">Unmapped</h4>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<ul class="list-unstyled small mb-3 flex-grow-1">
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Unmapped>">Subscribe</button></span>
|
||||
<small id="move-disabled-<ladder:Prices>-<product:Unmapped>" class="text-muted d-block mt-1">Not available for purchase yet</small>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<div class="card h-100">
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h4 class="card-title mb-0 h6">Elsewhere</h4>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<ul class="list-unstyled small mb-3 flex-grow-1">
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Elsewhere>">Subscribe</button></span>
|
||||
<small id="move-disabled-<ladder:Prices>-<product:Elsewhere>" class="text-muted d-block mt-1">Not available for purchase yet</small>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<div class="card h-100">
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h4 class="card-title mb-0 h6">Bare</h4>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<p class="text-muted small mb-2">Included</p>
|
||||
|
||||
|
||||
<ul class="list-unstyled small mb-3 flex-grow-1">
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Bare>">Subscribe</button></span>
|
||||
<small id="move-disabled-<ladder:Prices>-<product:Bare>" class="text-muted d-block mt-1">Not available for purchase yet</small>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
{
|
||||
"Ladders": [
|
||||
{
|
||||
"Name": "Wiki",
|
||||
"SortOrder": 1,
|
||||
"Tiers": [
|
||||
{
|
||||
"ProductID": "<product:Free>",
|
||||
"Name": "Free",
|
||||
"Description": "",
|
||||
"Rank": 0,
|
||||
"Relation": "",
|
||||
"EntitlementFeatures": null,
|
||||
"MetadataFeatures": null,
|
||||
"PriceID": "",
|
||||
"Purchasable": false,
|
||||
"PriceText": "",
|
||||
"NoPriceTier": true,
|
||||
"MoveEnabled": false,
|
||||
"MoveKind": "",
|
||||
"MoveLadderID": "",
|
||||
"MoveLabel": "",
|
||||
"DisabledReason": "",
|
||||
"BillingPriceText": "",
|
||||
"BillingRenewsOn": "",
|
||||
"BillingPending": false,
|
||||
"LadderID": "",
|
||||
"MovesTo": "",
|
||||
"MovesOn": "",
|
||||
"EndsOn": "",
|
||||
"Suspension": "",
|
||||
"CanCancel": false,
|
||||
"CanKeep": false,
|
||||
"CancelBody": "",
|
||||
"Held": false
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
"Name": "Basic",
|
||||
"Description": "",
|
||||
"Rank": 1,
|
||||
"Relation": "",
|
||||
"EntitlementFeatures": null,
|
||||
"MetadataFeatures": null,
|
||||
"PriceID": "<price:Basic>",
|
||||
"Purchasable": true,
|
||||
"PriceText": "$5.00/month",
|
||||
"NoPriceTier": false,
|
||||
"MoveEnabled": false,
|
||||
"MoveKind": "",
|
||||
"MoveLadderID": "",
|
||||
"MoveLabel": "",
|
||||
"DisabledReason": "",
|
||||
"BillingPriceText": "",
|
||||
"BillingRenewsOn": "",
|
||||
"BillingPending": false,
|
||||
"LadderID": "",
|
||||
"MovesTo": "",
|
||||
"MovesOn": "",
|
||||
"EndsOn": "",
|
||||
"Suspension": "",
|
||||
"CanCancel": false,
|
||||
"CanKeep": false,
|
||||
"CancelBody": "",
|
||||
"Held": false
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
"Name": "Plus",
|
||||
"Description": "",
|
||||
"Rank": 2,
|
||||
"Relation": "",
|
||||
"EntitlementFeatures": [
|
||||
"1 Plan Site"
|
||||
],
|
||||
"MetadataFeatures": null,
|
||||
"PriceID": "<price:Plus>",
|
||||
"Purchasable": true,
|
||||
"PriceText": "$10.00/month",
|
||||
"NoPriceTier": false,
|
||||
"MoveEnabled": false,
|
||||
"MoveKind": "",
|
||||
"MoveLadderID": "",
|
||||
"MoveLabel": "",
|
||||
"DisabledReason": "",
|
||||
"BillingPriceText": "",
|
||||
"BillingRenewsOn": "",
|
||||
"BillingPending": false,
|
||||
"LadderID": "",
|
||||
"MovesTo": "",
|
||||
"MovesOn": "",
|
||||
"EndsOn": "",
|
||||
"Suspension": "",
|
||||
"CanCancel": false,
|
||||
"CanKeep": false,
|
||||
"CancelBody": "",
|
||||
"Held": false
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
"Name": "Standard",
|
||||
"Description": "",
|
||||
"Rank": 3,
|
||||
"Relation": "",
|
||||
"EntitlementFeatures": null,
|
||||
"MetadataFeatures": null,
|
||||
"PriceID": "<price:Standard>",
|
||||
"Purchasable": true,
|
||||
"PriceText": "$20.00/month",
|
||||
"NoPriceTier": false,
|
||||
"MoveEnabled": false,
|
||||
"MoveKind": "",
|
||||
"MoveLadderID": "",
|
||||
"MoveLabel": "",
|
||||
"DisabledReason": "",
|
||||
"BillingPriceText": "",
|
||||
"BillingRenewsOn": "",
|
||||
"BillingPending": false,
|
||||
"LadderID": "",
|
||||
"MovesTo": "",
|
||||
"MovesOn": "",
|
||||
"EndsOn": "",
|
||||
"Suspension": "",
|
||||
"CanCancel": false,
|
||||
"CanKeep": false,
|
||||
"CancelBody": "",
|
||||
"Held": false
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
"Name": "Pro",
|
||||
"Description": "",
|
||||
"Rank": 4,
|
||||
"Relation": "current",
|
||||
"EntitlementFeatures": [
|
||||
"10 Plan Sites"
|
||||
],
|
||||
"MetadataFeatures": null,
|
||||
"PriceID": "",
|
||||
"Purchasable": false,
|
||||
"PriceText": "",
|
||||
"NoPriceTier": false,
|
||||
"MoveEnabled": false,
|
||||
"MoveKind": "",
|
||||
"MoveLadderID": "",
|
||||
"MoveLabel": "",
|
||||
"DisabledReason": "",
|
||||
"BillingPriceText": "",
|
||||
"BillingRenewsOn": "",
|
||||
"BillingPending": false,
|
||||
"LadderID": "",
|
||||
"MovesTo": "",
|
||||
"MovesOn": "",
|
||||
"EndsOn": "",
|
||||
"Suspension": "",
|
||||
"CanCancel": false,
|
||||
"CanKeep": false,
|
||||
"CancelBody": "",
|
||||
"Held": false
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
"CancelsOn": ""
|
||||
},
|
||||
{
|
||||
"Name": "Duo",
|
||||
"SortOrder": 2,
|
||||
"Tiers": [
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
"Name": "Plus",
|
||||
"Description": "",
|
||||
"Rank": 0,
|
||||
"Relation": "",
|
||||
"EntitlementFeatures": [
|
||||
"1 Plan Site"
|
||||
],
|
||||
"MetadataFeatures": null,
|
||||
"PriceID": "<price:Plus>",
|
||||
"Purchasable": true,
|
||||
"PriceText": "$10.00/month",
|
||||
"NoPriceTier": false,
|
||||
"MoveEnabled": false,
|
||||
"MoveKind": "",
|
||||
"MoveLadderID": "",
|
||||
"MoveLabel": "",
|
||||
"DisabledReason": "",
|
||||
"BillingPriceText": "",
|
||||
"BillingRenewsOn": "",
|
||||
"BillingPending": false,
|
||||
"LadderID": "",
|
||||
"MovesTo": "",
|
||||
"MovesOn": "",
|
||||
"EndsOn": "",
|
||||
"Suspension": "",
|
||||
"CanCancel": false,
|
||||
"CanKeep": false,
|
||||
"CancelBody": "",
|
||||
"Held": false
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
"Name": "Duo Max",
|
||||
"Description": "",
|
||||
"Rank": 1,
|
||||
"Relation": "",
|
||||
"EntitlementFeatures": null,
|
||||
"MetadataFeatures": null,
|
||||
"PriceID": "<price:Duo Max>",
|
||||
"Purchasable": true,
|
||||
"PriceText": "$40.00/month",
|
||||
"NoPriceTier": false,
|
||||
"MoveEnabled": false,
|
||||
"MoveKind": "",
|
||||
"MoveLadderID": "",
|
||||
"MoveLabel": "",
|
||||
"DisabledReason": "",
|
||||
"BillingPriceText": "",
|
||||
"BillingRenewsOn": "",
|
||||
"BillingPending": false,
|
||||
"LadderID": "",
|
||||
"MovesTo": "",
|
||||
"MovesOn": "",
|
||||
"EndsOn": "",
|
||||
"Suspension": "",
|
||||
"CanCancel": false,
|
||||
"CanKeep": false,
|
||||
"CancelBody": "",
|
||||
"Held": false
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
"CancelsOn": ""
|
||||
},
|
||||
{
|
||||
"Name": "Prices",
|
||||
"SortOrder": 3,
|
||||
"Tiers": [
|
||||
{
|
||||
"ProductID": "<product:Entry>",
|
||||
"Name": "Entry",
|
||||
"Description": "",
|
||||
"Rank": 0,
|
||||
"Relation": "available",
|
||||
"EntitlementFeatures": null,
|
||||
"MetadataFeatures": null,
|
||||
"PriceID": "<price:Entry>",
|
||||
"Purchasable": true,
|
||||
"PriceText": "$5.00/month",
|
||||
"NoPriceTier": false,
|
||||
"MoveEnabled": true,
|
||||
"MoveKind": "checkout",
|
||||
"MoveLadderID": "<ladder:Prices>",
|
||||
"MoveLabel": "Subscribe",
|
||||
"DisabledReason": "",
|
||||
"BillingPriceText": "",
|
||||
"BillingRenewsOn": "",
|
||||
"BillingPending": false,
|
||||
"LadderID": "",
|
||||
"MovesTo": "",
|
||||
"MovesOn": "",
|
||||
"EndsOn": "",
|
||||
"Suspension": "",
|
||||
"CanCancel": false,
|
||||
"CanKeep": false,
|
||||
"CancelBody": "",
|
||||
"Held": false
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
"Name": "Once",
|
||||
"Description": "",
|
||||
"Rank": 1,
|
||||
"Relation": "available",
|
||||
"EntitlementFeatures": null,
|
||||
"MetadataFeatures": null,
|
||||
"PriceID": "<price:Once>",
|
||||
"Purchasable": true,
|
||||
"PriceText": "",
|
||||
"NoPriceTier": false,
|
||||
"MoveEnabled": false,
|
||||
"MoveKind": "checkout",
|
||||
"MoveLadderID": "<ladder:Prices>",
|
||||
"MoveLabel": "Subscribe",
|
||||
"DisabledReason": "Not available for purchase yet",
|
||||
"BillingPriceText": "",
|
||||
"BillingRenewsOn": "",
|
||||
"BillingPending": false,
|
||||
"LadderID": "",
|
||||
"MovesTo": "",
|
||||
"MovesOn": "",
|
||||
"EndsOn": "",
|
||||
"Suspension": "",
|
||||
"CanCancel": false,
|
||||
"CanKeep": false,
|
||||
"CancelBody": "",
|
||||
"Held": false
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
"Name": "Unmapped",
|
||||
"Description": "",
|
||||
"Rank": 2,
|
||||
"Relation": "available",
|
||||
"EntitlementFeatures": null,
|
||||
"MetadataFeatures": null,
|
||||
"PriceID": "<price:Unmapped>",
|
||||
"Purchasable": false,
|
||||
"PriceText": "",
|
||||
"NoPriceTier": false,
|
||||
"MoveEnabled": false,
|
||||
"MoveKind": "checkout",
|
||||
"MoveLadderID": "<ladder:Prices>",
|
||||
"MoveLabel": "Subscribe",
|
||||
"DisabledReason": "Not available for purchase yet",
|
||||
"BillingPriceText": "",
|
||||
"BillingRenewsOn": "",
|
||||
"BillingPending": false,
|
||||
"LadderID": "",
|
||||
"MovesTo": "",
|
||||
"MovesOn": "",
|
||||
"EndsOn": "",
|
||||
"Suspension": "",
|
||||
"CanCancel": false,
|
||||
"CanKeep": false,
|
||||
"CancelBody": "",
|
||||
"Held": false
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
"Name": "Elsewhere",
|
||||
"Description": "",
|
||||
"Rank": 3,
|
||||
"Relation": "available",
|
||||
"EntitlementFeatures": null,
|
||||
"MetadataFeatures": null,
|
||||
"PriceID": "<price:Elsewhere>",
|
||||
"Purchasable": false,
|
||||
"PriceText": "",
|
||||
"NoPriceTier": false,
|
||||
"MoveEnabled": false,
|
||||
"MoveKind": "checkout",
|
||||
"MoveLadderID": "<ladder:Prices>",
|
||||
"MoveLabel": "Subscribe",
|
||||
"DisabledReason": "Not available for purchase yet",
|
||||
"BillingPriceText": "",
|
||||
"BillingRenewsOn": "",
|
||||
"BillingPending": false,
|
||||
"LadderID": "",
|
||||
"MovesTo": "",
|
||||
"MovesOn": "",
|
||||
"EndsOn": "",
|
||||
"Suspension": "",
|
||||
"CanCancel": false,
|
||||
"CanKeep": false,
|
||||
"CancelBody": "",
|
||||
"Held": false
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
"Name": "Bare",
|
||||
"Description": "",
|
||||
"Rank": 4,
|
||||
"Relation": "available",
|
||||
"EntitlementFeatures": null,
|
||||
"MetadataFeatures": null,
|
||||
"PriceID": "",
|
||||
"Purchasable": false,
|
||||
"PriceText": "",
|
||||
"NoPriceTier": true,
|
||||
"MoveEnabled": false,
|
||||
"MoveKind": "checkout",
|
||||
"MoveLadderID": "<ladder:Prices>",
|
||||
"MoveLabel": "Subscribe",
|
||||
"DisabledReason": "Not available for purchase yet",
|
||||
"BillingPriceText": "",
|
||||
"BillingRenewsOn": "",
|
||||
"BillingPending": false,
|
||||
"LadderID": "",
|
||||
"MovesTo": "",
|
||||
"MovesOn": "",
|
||||
"EndsOn": "",
|
||||
"Suspension": "",
|
||||
"CanCancel": false,
|
||||
"CanKeep": false,
|
||||
"CancelBody": "",
|
||||
"Held": false
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
"CancelsOn": ""
|
||||
}
|
||||
],
|
||||
"Error": "",
|
||||
"Success": ""
|
||||
}
|
||||
@@ -225,7 +225,7 @@ type Paying struct {
|
||||
}
|
||||
```
|
||||
|
||||
The read: `ListSubscriptionRungsByOrg` (billing), one statement per request, read once per request and cached on the `positionLoader` as `predictions` is cached (`positions()` is not cached): the organization's live subscriptions (`active`, `trialing`, `past_due`, `unpaid`, `paused`) with their status, period end, `cancel_at_period_end`, the mirror's `pause_collection_behavior` and `pause_collection_marked`, the first scheduled cancellation and plan switch (joined as `ListLadderPositionsByPool` joins them, `internal/entitlements/queries/pool_provision_ladders.sql:212-243`), and each current item's product, its price (id, `unit_amount`, `currency`, `recurring_interval`, so the paid card names the subscription's own price with no read of its own, D10, D20) and the product's tier rows (`core.plan_ladder_tiers`: ladder and rank), ordered by ladder, subscription id and product id. `payingByLadder(rows, positions)` (reading no row) keeps, per ladder, the first subscription that does not hold that ladder's position, and sets `Paused` from the status, `ConsolePaused` from the behavior and the marker together, `MoveScheduled` from `cancel_at_period_end` or either scheduled row, and `OtherLadders` when the product's tier rows name another ladder. `onLadder` gains the ladder's paying row and sets `pos.Paying`; `GrantHolds` is the held position's `grant_id` being set while its `pool_provision_ladders.default_sourced` is false, because a default position is a grant row with `grant_reason = 'default'` and must not read as a grant; `ListLadderPositionsByPool` carries `grant_id` and gains `default_sourced`. The choice of a ladder's paying subscription is one pure function in `entitlements` beside `PositionsByLadder`, which `payingByLadder` and the resolver (D11) both call, so the rule is stated once. Section 9 builds every field; `ConsolePaused`, `MoveScheduled` and `OtherLadders` are read by the rule from section 12 on.
|
||||
The read: `ListSubscriptionRungsByOrg` (billing), one statement per pass over the organization's ladders, read once per `positionLoader` and cached on it (a switch's re-render after the move builds a new loader and reads again, since it must show the state the move left) as `predictions` is cached (`positions()` is not cached): the organization's live subscriptions (`active`, `trialing`, `past_due`, `unpaid`, `paused`) with their status, period end, `cancel_at_period_end`, the mirror's `pause_collection_behavior` and `pause_collection_marked`, the first scheduled cancellation and plan switch (joined as `ListLadderPositionsByPool` joins them, `internal/entitlements/queries/pool_provision_ladders.sql:212-243`), and each current item's product, its price (id, `unit_amount`, `currency`, `recurring_interval`, so the paid card names the subscription's own price with no read of its own, D10, D20) and the product's tier rows (`core.plan_ladder_tiers`: ladder and rank), ordered by ladder, subscription id and product id. `payingByLadder(rows, positions)` (reading no row) keeps, per ladder, the first subscription that does not hold that ladder's position, and sets `Paused` from the status, `ConsolePaused` from the behavior and the marker together, `MoveScheduled` from `cancel_at_period_end` or either scheduled row, and `OtherLadders` when the product's tier rows name another ladder. `onLadder` gains the ladder's paying row and sets `pos.Paying`; `GrantHolds` is the held position's `grant_id` being set while its `pool_provision_ladders.default_sourced` is false, because a default position is a grant row with `grant_reason = 'default'` and must not read as a grant; `ListLadderPositionsByPool` carries `grant_id` and gains `default_sourced`. The choice of a ladder's paying subscription is one pure function in `entitlements` beside `PositionsByLadder`, which `payingByLadder` and the resolver (D11) both call, so the rule is stated once. Section 9 builds every field; `ConsolePaused`, `MoveScheduled` and `OtherLadders` are read by the rule from section 12 on.
|
||||
|
||||
The rule, rule 3a, ahead of rule 3 (the target is the current product), applied when `Paying` is set and no subscription backs the held position (`!pos.Subscribed`, the condition of D11's fallback), so a subscription that holds the rung over another one, as a Dashboard-made subscription can, keeps its own moves:
|
||||
1. the target is the paying product: Resume, enabled, relation `Upgrade`, when D20's conditions hold (a grant ranked below the paying product holds the rung, the subscription is not suspended, its row records the console's pause, nothing is scheduled on it, and its product is a tier of this ladder alone); otherwise none, reason `PaidHere` ("a subscription pays for the target without holding the ladder"). In the section 9 commit this item always answers none, reason `PaidHere`, and the section 12 commit adds Resume;
|
||||
@@ -328,7 +328,7 @@ Stripe accepts the pause on a subscription a schedule manages, and keeps it thro
|
||||
|
||||
A pause not yet confirmed or failed reads the existing continues title, which is true, and its entry shows on the Stripe provider page as retrying and then as a dead letter. `subscriptionMayBeDisplaced` is unchanged: a `paused` subscription still shows no badge. The list's `ListSubscriptionsPage` gains `s.pause_collection_behavior`.
|
||||
|
||||
The grant form's warning, `issueGrantSupersedeWarning`, reads "This grant would supersede a tier a subscription pays for; its billing pauses until the grant ends." It is the Product field's hint line, shown only once the operator chooses a product marked ⚠ (a form-level alert shown whenever the select offers a marked product read as a standing warning on every opening of the form, so the sentence sits with the choice it describes). It is true of a past-due subscription's future renewals, and of a `paused` one, which Stripe does not invoice.
|
||||
The grant form's warning, `issueGrantSupersedeWarning`, reads "This grant would supersede a tier a subscription pays for; its billing pauses until the grant ends." It is the Product field's hint line, shown only once the operator chooses a product marked ⚠ (a form-level alert shown whenever the select offers a marked product read as a standing warning on every opening of the form, so the sentence sits with the choice it describes). A chosen tier marked ⚠ whose entitlement set has no active rule also shows the rule-less sentence the field's warning slot loads on change, so the field shows two lines in that state: each states a different consequence of the same choice, the grant providing nothing and the subscription's billing pausing, and hiding either would hide a cost the operator should see before issuing. It is true of a past-due subscription's future renewals, and of a `paused` one, which Stripe does not invoice.
|
||||
|
||||
The Overview: `SumMonthlyRecurringByCurrency` gains `AND s.pause_collection_behavior IS NULL`. The caption is unchanged: a subscription whose collection is paused keeps its `active` status and is counted as active.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ The console SHALL decide which move an organization is offered toward a target p
|
||||
- the position: the ladder's tiers and their ranks; the product the organization holds on the ladder, if any, through a position that is active or suspended; whether a subscription pays for that rung, as opposed to a grant, the organization type's default or a purchase; whether the position is suspended, which its ladder row's status says (the subscription reconcile suspends it while the subscription is `past_due`, `unpaid` or `paused`); whether it is paused, which the subscription's status `paused` says; whether the subscription's cancellation is scheduled; the product an active scheduled downgrade moves to; the held rung (`member-product-discovery`, "Plan-move controls reflect built billing mechanics"); when the cancel prediction exists, the product it puts on the ladder, if any; and, when a live subscription of the organization pays for a tier of the ladder without holding the ladder's rung (the paying subscription), the tier it pays for and its rank, whether a grant holds the rung, whether the paying subscription is suspended (its status is `past_due`, `unpaid` or `paused`) and whether it is `paused`, whether its `core.subscriptions` row records a `pause_collection` behavior together with the console's marker, whether a cancellation or a downgrade is scheduled on it, and whether its product is a tier of another ladder too;
|
||||
- the target: the product; whether it clears the member gate (published, active, public); and the target price, with whether it is the product's default price (price-management, "Each product has at most one default price, which every purchase surface tracks"), whether it is recurring, and whether its Stripe mapping carries an id the current key can reach. A product with no active price is a target with no price.
|
||||
|
||||
The paying subscription SHALL be read from the organization's live subscriptions (status `active`, `trialing`, `past_due`, `unpaid` or `paused`), their current items, and the tiers of the items' products, in one statement per request, never one per ladder; on each ladder the first such subscription by id that does not hold the ladder's position is the paying subscription.
|
||||
The paying subscription SHALL be read from the organization's live subscriptions (status `active`, `trialing`, `past_due`, `unpaid` or `paused`), their current items, and the tiers of the items' products, in one statement per pass over the organization's ladders, never one per ladder (a switch that moves and then renders the page again makes two passes, the second reading the state the move left); on each ladder the first such subscription by id that does not hold the ladder's position is the paying subscription.
|
||||
|
||||
Its answer SHALL be one move: Checkout, a switch, a downgrade at the period end (the cancellation after which the held rung or the free rung follows), Resume (the paying subscription takes the ladder back from a lower grant at its own price), or none; whether an offered move is enabled; and the reason when no move is offered or the move is disabled. It SHALL route moves as `member-product-discovery`'s "Plan-move controls reflect built billing mechanics" states, and in addition:
|
||||
|
||||
|
||||
@@ -248,10 +248,10 @@ Sections 1 to 12 are one commit each, and their items are that commit's parts; s
|
||||
|
||||
## 9. The offer decision knows a subscription that pays without holding (#277)
|
||||
|
||||
- [ ] 9.1 `internal/billing/queries/subscriptions.sql`: `ListSubscriptionRungsByOrg` as design D9 states, with the mirror's behavior and marker, the scheduled cancellation and switch, and each item's price facts; `ListLadderPositionsByPool` (`internal/entitlements/queries/pool_provision_ladders.sql:212-243`) gains `default_sourced`, so that `GrantHolds` is false when the default holds the rung (design D9). `make sqlc-generate`.
|
||||
- [ ] 9.2 `internal/plans/offer.go`: `Paying` with every field of design D9, and `Position.Paying`; reasons `PaidHere` ("a subscription pays for the target on this ladder without holding it") and `PaysOnLadder` ("a subscription pays on the ladder without holding it"); rule 3a ahead of rule 3 in `Offer`, applied when `Paying` is set and the held position is not subscription-backed (`!pos.Subscribed`), with the rules' doc comment renumbered or the rule named 3a there. In this commit its item 1 (the paying product) answers none, reason `PaidHere`, and section 12 adds Resume; its item 2 (a grant holds, the paying subscription is not suspended, the target ranks at or above the held rung and above the paying product) answers none, reason `PaysOnLadder`, and section 11 turns it into Switch; the rank test against the paying product is question 3's answer (design D9). `Across` is unchanged: rule 3a answers no Checkout on any paying ladder, so a product with such a ladder already combines to none (`checkoutOffer`'s existing `Across` call refuses it, `internal/server/billing.go:284-290`).
|
||||
- [ ] 9.3 `internal/server/plan_offer.go`: `positionLoader` reads `ListSubscriptionRungsByOrg` once per request, cached as `predictions` is (`positions()` is not cached); `payingByLadder(rows, positions) map[string]plans.Paying`, reading no row, sets every field as design D9 states, `GrantHolds` being the held position's `grant_id` set and `default_sourced` false; the choice of a ladder's paying subscription is one pure function in `internal/entitlements` beside `PositionsByLadder`, which `payingByLadder` and `resolveSubscriptionOnLadder` (10.2) both call; `onLadder` sets `pos.Paying`. `switchOffer`, under rule 3a's own condition (`pos.Paying != nil && !pos.Subscribed`), maps a refusal whose `Paying` is suspended to `ErrSubscriptionSuspended`, or `ErrSubscriptionPaused` when `Paying.Paused`, and after that a refusal whose `Paying` is not suspended to `errPlanNotOffered`, both ahead of its `!pos.Subscribed` case, which then answers only a ladder no subscription pays on, so a subscription that holds the rung over a paying one keeps its own refusals (design D9); its doc comment states the order and the condition. The switch and its preview both ask `switchOffer`, so both answer the unavailable-plan sentence on such a ladder.
|
||||
- [ ] 9.4 Tests:
|
||||
- [x] 9.1 `internal/billing/queries/subscriptions.sql`: `ListSubscriptionRungsByOrg` as design D9 states, with the mirror's behavior and marker, the scheduled cancellation and switch, and each item's price facts; `ListLadderPositionsByPool` (`internal/entitlements/queries/pool_provision_ladders.sql:212-243`) gains `default_sourced`, so that `GrantHolds` is false when the default holds the rung (design D9). `make sqlc-generate`.
|
||||
- [x] 9.2 `internal/plans/offer.go`: `Paying` with every field of design D9, and `Position.Paying`; reasons `PaidHere` ("a subscription pays for the target on this ladder without holding it") and `PaysOnLadder` ("a subscription pays on the ladder without holding it"); rule 3a ahead of rule 3 in `Offer`, applied when `Paying` is set and the held position is not subscription-backed (`!pos.Subscribed`), with the rules' doc comment renumbered or the rule named 3a there. In this commit its item 1 (the paying product) answers none, reason `PaidHere`, and section 12 adds Resume; its item 2 (a grant holds, the paying subscription is not suspended, the target ranks at or above the held rung and above the paying product) answers none, reason `PaysOnLadder`, and section 11 turns it into Switch; the rank test against the paying product is question 3's answer (design D9). `Across` is unchanged: rule 3a answers no Checkout on any paying ladder, so a product with such a ladder already combines to none (`checkoutOffer`'s existing `Across` call refuses it, `internal/server/billing.go:284-290`).
|
||||
- [x] 9.3 `internal/server/plan_offer.go`: `positionLoader` reads `ListSubscriptionRungsByOrg` once per loader, cached as `predictions` is (a switch's re-render after its move builds a second loader) (`positions()` is not cached); `payingByLadder(rows, positions) map[string]plans.Paying`, reading no row, sets every field as design D9 states, `GrantHolds` being the held position's `grant_id` set and `default_sourced` false; the choice of a ladder's paying subscription is one pure function in `internal/entitlements` beside `PositionsByLadder`, which `payingByLadder` and `resolveSubscriptionOnLadder` (10.2) both call; `onLadder` sets `pos.Paying`. `switchOffer`, under rule 3a's own condition (`pos.Paying != nil && !pos.Subscribed`), maps a refusal whose `Paying` is suspended to `ErrSubscriptionSuspended`, or `ErrSubscriptionPaused` when `Paying.Paused`, and after that a refusal whose `Paying` is not suspended to `errPlanNotOffered`, both ahead of its `!pos.Subscribed` case, which then answers only a ladder no subscription pays on, so a subscription that holds the rung over a paying one keeps its own refusals (design D9); its doc comment states the order and the condition. The switch and its preview both ask `switchOffer`, so both answer the unavailable-plan sentence on such a ladder.
|
||||
- [x] 9.4 Tests:
|
||||
- `TestOffer` rows (`internal/plans/offer_test.go`): `the paid tier` (none, PaidHere); `a tier between a lower grant and the paid tier` (none, PaysOnLadder); `a tier below the grant with a paying subscription` (none, PaysOnLadder); `a past-due paying subscription` (none for every target); `a paused paying subscription` (none); `a bundle's vacated ladder` (none, PaysOnLadder, no Checkout); `the default holds the rung` (`GrantHolds` false: no Resume and no switch for any target); `a subscription that holds the rung over another one` (rule 3a does not apply: the held subscription's target above it is a switch, with no `PaysOnLadder` reason); and the existing `a higher tier over a grant is Checkout` row, with no `Paying`, unchanged. Failure text unchanged.
|
||||
- `TestAcross` passes unedited: it takes moves, not positions, and rule 3a already answers no Checkout.
|
||||
- `TestPayingByLadder` (`internal/server/plan_offer_test.go`, no database), rows: `the holding subscription` (never paying); `two subscriptions on one ladder` (the lower id); `a bundle` (both ladders, `OtherLadders` set on each); `the default holds the rung` (`GrantHolds` false); `the console's pause recorded` (`ConsolePaused`); `a pause made in the Dashboard` (the behavior without the marker: `ConsolePaused` false); `a marker with no pause` (`ConsolePaused` false); `a cancellation scheduled` and `a downgrade scheduled` (`MoveScheduled`); `paused` (`Suspended` and `Paused`). Failure text: `payingByLadder(%s) = %v, want %v`.
|
||||
@@ -259,17 +259,24 @@ Sections 1 to 12 are one commit each, and their items are that commit's parts; s
|
||||
- `TestCheckoutRefusesATierBelowOrAtAGrantedRung` and the existing Checkout tests pass unedited; `TestCheckoutAboveAGrantWithNoSubscription` asserts the session is still created. Failure text: `checkout above a grant with no subscription answered %d`.
|
||||
- `TestMemberPlansGolden`: a new state `paid-plus-under-granted-pro` (the kit's `Subscribe(o, plus)`, then `Issue(o, pro)`, then `PauseCollection`), goldens `internal/server/testdata/member_plans/paid-plus-under-granted-pro.html` and `.json` written with `-update` and read: no card offers Checkout and none offers a switch yet. Plus is a tier of Wiki and Duo, so Duo's Plus card draws its price and no control, and Duo Max offers no Checkout. The card's own state comes in section 10 and the switch in section 11, whose goldens change again. No existing state's `.html` or `.json` changes, because no existing state has a subscription that pays on a ladder without holding it.
|
||||
- `TestProductsPageReadsPayingOnce` (`internal/server/member_plans_read_count_db_test.go`, new, counting statements as `TestOperatorPagesReadAFixedNumberOfTimes` does): the page runs one statement more than before, for any number of ladders; its fixture holds no ladder a grant holds against a paying subscription. Failure text: `the Products page ran %d statements, want %d`.
|
||||
- [ ] 9.5 Breaks:
|
||||
- Added at review: rows for a failed read of the paying subscriptions in `TestPlansShowTheLoadErrorWhenAReadFails`, `TestCheckoutRefusesWhenAReadFails` and `TestSwitchRefusesWhenAReadFails`; `TestPayingByLadder` rows `unpaid` and `two tiers of one subscription on a ladder`; `TestCheckoutRefusesALadderASubscriptionPaysFor` rows for a past_due, an unpaid, a paused and a trialing displaced subscription, its body compared with the Copy sentence and printed in its failure text (`%s: checkout answered %d, want 422, with %d Stripe calls, body %q`); `TestCheckoutAboveAGrantWithNoSubscription` as two controls, Pro and Duo Max, and the row `a ladder only an ended item names`, which sells.
|
||||
- [x] 9.5 Breaks:
|
||||
- rule 3a applied when the held position is subscription-backed (the `!pos.Subscribed` test dropped): caught by `TestOffer`, `want_new` `Offer(a subscription that holds the rung over another one) =`;
|
||||
- rule 3a's item 4 falling through to the route (a tier of a bundle's vacated ladder answers Checkout, the row `the vacated ladder of a bundle`): caught by `TestCheckoutRefusesALadderASubscriptionPaysFor`, `want_new` `checkout answered 303` (`postReal` sends no `HX-Request`, so a sold Checkout answers 303);
|
||||
- `GrantHolds` read from `grant_id` alone: caught by `TestPayingByLadder`, `want_new` `payingByLadder(the default holds the rung) =`;
|
||||
- the holding subscription counted as paying: caught by `TestPayingByLadder`, `want_new` `payingByLadder(the holding subscription) =`;
|
||||
- `ConsolePaused` read from the behavior alone: caught by the same, `want_new` `payingByLadder(a pause made in the Dashboard) =`;
|
||||
- `MoveScheduled` read from `cancel_at_period_end` alone: caught by the same, `want_new` `payingByLadder(a downgrade scheduled) =`;
|
||||
- the loader reading per ladder: caught by the read-count test, `want_new` `the Products page ran`.
|
||||
- the loader reading per ladder: caught by the read-count test, `want_new` `the Products page ran`;
|
||||
- a failed read of the paying subscriptions answered as none: caught by `TestCheckoutRefusesWhenAReadFails`, `want_new` `status 422`;
|
||||
- `past_due`, `unpaid`, `paused` and `trialing` each dropped from the read's statuses: caught by `TestCheckoutRefusesALadderASubscriptionPaysFor`, `want_new` `checkout answered 303`;
|
||||
- `Suspended` without `unpaid`: caught by `TestPayingByLadder`, `want_new` `payingByLadder(unpaid) =`;
|
||||
- the higher product id taken for a subscription paying for two tiers of one ladder: caught by the same, `want_new` `payingByLadder(two tiers of one subscription on a ladder) =`;
|
||||
- an ended item counted as paying (`AND si.ended_at IS NULL` dropped): caught by `TestCheckoutAboveAGrantWithNoSubscription`, `want_new` `answered 422`.
|
||||
- Unseen by any test: `l.default_sourced` read as false in the query (the default floor never holds a rung a subscription pays for in any fixture of the section).
|
||||
- Unseen by any test of the section, named in the commit message, because no section-9 test posts a switch on a paying ladder: `switchOffer`'s paying refusal left to `!pos.Subscribed` (the `Paying`-set, not-suspended case deleted); the suspended mapping deleted; the `Paying.Paused` test dropped; and the `!pos.Subscribed` test dropped from the mappings' condition. 11.6 records each, caught by `TestPostSwitchFromAPausedSubscription`.
|
||||
- [ ] 9.6 `make screens`: no demo state has a paying subscription, so no capture changes; a change is read and named.
|
||||
- [ ] 9.7 Docs: invariant 19 of `docs/models/plan-ladders-transitions.md` ("from a grant-held rung the only move offered is up, through Checkout") says that on a ladder a subscription pays for without holding, no tier is sold through Checkout, the switch of the paying subscription is offered at or above the grant and above the paid tier, and the paid tier resumes under a lower grant (sections 11 and 12 make both live); invariant 12 of `docs/models/payments-billing.md` (`:65`) says that Checkout refuses a ladder a subscription pays for whether or not the subscription holds it.
|
||||
- [x] 9.6 `make screens`: no demo state has a paying subscription, so no capture changes; a change is read and named.
|
||||
- [x] 9.7 Docs: invariant 19 of `docs/models/plan-ladders-transitions.md` ("from a grant-held rung the only move offered is up, through Checkout") says that on a ladder a subscription pays for without holding, no tier is sold through Checkout, the switch of the paying subscription is offered at or above the grant and above the paid tier, and the paid tier resumes under a lower grant (sections 11 and 12 make both live); invariant 12 of `docs/models/payments-billing.md` (`:65`) says that Checkout refuses a ladder a subscription pays for whether or not the subscription holds it.
|
||||
|
||||
## 10. The paid plan's card, Cancel plan, Keep plan and Pay invoice (#269)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user