Let a member resume a paid plan a lower grant holds, and show a grant's end date

Twelfth section of #269's change. When the grant that holds a ladder
ranks below the tier a paused subscription pays for, the paid plan's
card now offers Resume plan before Cancel plan. Resume plan is a
console act (entitlements.ResumeSubscription, run by
fulfillment.ResumePlan from PostResume): in one transaction under the
pool row it confers the subscription's own tier over the grant and
writes the reconcile requests, and the outbox's reconcile then clears
the pause at Stripe after the commit, so the member's request makes no
Stripe call. It takes no subscription's advisory lock, so an ending act
or a reconcile holding that lock goes first; a second submit confers
once; over a bundle grant it ends the grant on its other ladder too. It
is refused when its conditions no longer hold: a suspended subscription
answers as a past-due or paused plan does, and a scheduled move, a rung
the subscription or another one already holds, or a Stripe mapping the
outbox cannot reach answers the unavailable-plan sentence.
Its confirmation reads "Resume <paid>?" and "Your subscription replaces
the grant. Nothing is charged now; then <price>." A grant marked to
resume delivers again when the subscription ends.

Rides along, by ruling: the current card a grant holds now states its
cost like the others, reading "Granted until <date>" when the grant has
an end date and "Granted" when it has none (the grant's valid_until,
read with the positions in ListLadderPositionsByPool). Under a grant of
the subscription's own tier, where the paid card is the current card,
its paused line carries the date: "Paused while Plus is granted until
Feb 20, 2099". The organization type's default plan's card, which
states no cost either, is #289. Also by ruling, the switch on the
granted tier's own card reads "Pay for this plan" and its confirmation
"Pay for <tier>?" (Move.OwnTier); the tiers above keep "Upgrade".

docs/stripe.md, docs/plan-management.md,
docs/models/plan-ladders-transitions.md and docs/database-locks.md say
the same.

Tests: TestResumeSubscription_* (taking the ladder from the grant,
refusals, an ending act first, a second submit, a bundle grant, a
marked grant resuming), TestResumePlan_* (no Stripe call, the act's
refusals mapped, an unreachable mapping, a failed clear keeping the
ladder), TestPostResume, TestResumeConfirmation,
TestRenderPlansAfterMoveAnswersResume, TestGrantedCurrentCard,
TestSwitchLabelUnderAGrant, TestOffer rows, and TestPaidPlanCardUnderAGrant
rows for the dated own-tier lines. TestMemberPlansGolden gains four
states (paid-standard-under-granted-basic and three dated ones); nine
states gain "Granted" on the granted current card, and
paid-retired-plus-under-granted-pro's switch reads "Pay for this plan".
The routes goldens gain the resume route.
TestResumePlan_AFailedClearKeepsTheLadder runs the drift sweep where the
task named collectionDrift; the sweep reaches the same decision.

Full suite on reset databases: 5090 passed, 14 skipped, none failed.
make screens: no page changed; no demo state reaches the paid card.

Breaks, all CAUGHT (43):
- Resume: the rank test dropped from rule 3a.1
- Resume: the console-pause test dropped from rule 3a.1
- Resume: the scheduled-move test dropped from rule 3a.1
- Resume: the bundle test dropped from rule 3a.1
- Resume: the suspension test dropped from rule 3a.1
- Resume: the GrantHolds test dropped from rule 3a.1
- ResumeSubscription: the status test dropped
- ResumeSubscription: the materialization dropped
- ResumePlan: the act's refusal mapping dropped
- ResumeSubscription: the marker test dropped (a behavior alone passes)
- ResumeSubscription: the rank test dropped
- ResumeSubscription: the scheduled-change read dropped (cancel_at_period_end alone)
- ResumeSubscription: the holds test dropped
- ResumeSubscription: the reconcile requests dropped
- ResumeSubscription: the reads moved above LockPools
- ResumeSubscription: the reconcile's advisory lock on the subscription's Stripe id taken after LockPools
- ResumePlan: the reachability test dropped
- Resume plan: the trigger's declaration removed
- Resume plan: the confirmation's first sentence dropped
- Resume plan: the control not drawn (CanResume ignored by the template)
- Resume plan: CanResume not counted in plansOfferPaidMove
- Resume plan: the handler's decision gate dropped
- Granted card: GrantedUntil never set (TestGrantedCurrentCard)
- Granted card: GrantedUntil never set (TestPaidPlanCardUnderAGrant)
- Granted card: GrantedUntil never set (TestMemberPlansGolden/granted-plus-until)
- Granted card: the template's two branches removed
- Granted card: setGranted called only when a subscription pays on the ladder
- Granted card: the default_sourced test dropped
- Granted card: pos.GrantID.Valid dropped from setGranted
- Granted card: a clock read added (a passed end date drawn as Granted)
- Granted card: setGranted called after the paid card's return
- Granted card: setGranted also called on the paid card under a grant of another tier
- Granted card: the date left out of the plain paused branch (TestPaidPlanCardUnderAGrant)
- Granted card: the date left out of the plain paused branch (TestMemberPlansGolden/granted-plus-own-tier-until)
- Granted card: the date left out of the payment-due paused branch
- Granted card: the date left out of the downgrade's paused branch
- Granted card: the date drawn on a line that does not read Paused (the renews branch)
- Granted card: the query's column selected as NULL (the join left out)
- Granted card: JOIN core.grants in place of the LEFT JOIN (the new test)
- Granted card: JOIN core.grants in place of the LEFT JOIN (the live-positions test)
- renderPlansAfterMove: ErrResumeUnavailable dropped from the unavailable-plan case
- moveLabel: the own-tier case reverted to Upgrade
- planMoveMessage: the own-tier heading reverted to Switch to
This commit is contained in:
2026-10-29 18:07:46 -05:00
parent 23e5689df8
commit 688e36227b
83 changed files with 7462 additions and 421 deletions
+12 -1
View File
@@ -41,7 +41,7 @@ product row `FOR SHARE` (see the product row below).
| Lock | Row | Mode | Taken by | Serializes |
|---|---|---|---|---|
| Pool row | `core.resource_pools` row of one pool | `FOR UPDATE`; `FOR KEY SHARE` in a plan act's listing batch | the functions `core.confer`, `core.end_conferral`, `core.align_conferral_shape`, `core.update_conferral_bounds` and `core.settle_obligation`; `LockPools` (through the `LockResourcePool` query), called by `RevokeGrantTx`, `ManageGrantTx`, `ResumeReplacedGrantsTx`, `settlePool`, `reconcileItems`, `reconcileSuspended` and `reconcileEnded` (the subscription reconcile's active, suspended and ended branches), `runPlanAct` (`AddTier`, `RemoveTier` and `EnactObligation`, a default change's move), and `runPlanAct` for a reorder's move (`EnactObligation`); `LockPoolOfGrant` (through the `LockGrantPool` query), called by `ExpireGrantTx` and `ExtendWaitingGrantTx`; and `core.write_plan_act_obligations`, which takes the pool rows of one listing batch `FOR KEY SHARE` | Everything that changes a pool's positions or entitlements: conferral, endings, a subscription's suspension and resume, grant acts, tier removal, a default change's move, rule-change settlement and grant resumption. A listing batch's key-share lock conflicts with `FOR UPDATE`, so a batch and a move or a conferral on one of its pools wait for each other, and two batches of one act share the rows. |
| Pool row | `core.resource_pools` row of one pool | `FOR UPDATE`; `FOR KEY SHARE` in a plan act's listing batch | the functions `core.confer`, `core.end_conferral`, `core.align_conferral_shape`, `core.update_conferral_bounds` and `core.settle_obligation`; `LockPools` (through the `LockResourcePool` query), called by `RevokeGrantTx`, `ManageGrantTx`, `ResumeReplacedGrantsTx`, `ResumeSubscription`, `settlePool`, `reconcileItems`, `reconcileSuspended` and `reconcileEnded` (the subscription reconcile's active, suspended and ended branches), `runPlanAct` (`AddTier`, `RemoveTier` and `EnactObligation`, a default change's move), and `runPlanAct` for a reorder's move (`EnactObligation`); `LockPoolOfGrant` (through the `LockGrantPool` query), called by `ExpireGrantTx` and `ExtendWaitingGrantTx`; and `core.write_plan_act_obligations`, which takes the pool rows of one listing batch `FOR KEY SHARE` | Everything that changes a pool's positions or entitlements: conferral, endings, a subscription's suspension and resume, grant acts, tier removal, a default change's move, rule-change settlement, grant resumption and a member's Resume plan. A listing batch's key-share lock conflicts with `FOR UPDATE`, so a batch and a move or a conferral on one of its pools wait for each other, and two batches of one act share the rows. |
| Entitlement set row and rule row | `core.entitlement_sets` row and `core.entitlement_set_rules` row | `FOR UPDATE` | `core.commit_rule_change` (the set row also by the `LockEntitlementSet` query, once per batch) | A rule change against a writer that changes the set or the rule without holding the rendezvous. |
| Change obligation row | `core.entitlement_set_change_obligations` row of one (change, pool) | `FOR UPDATE` | `core.settle_obligation` | Two passes settling one owed pool, so each effect is written once. |
| Plan act obligation row | `core.plan_act_obligations` row of one (act, pool) | `FOR UPDATE` by a move, a listing batch's supersession and Retry's requeue; `UPDATE` by the failure recorder | the `LockPlanActObligation` query (a move, after the pool row), which calls `core.lock_plan_act_obligation`; the function takes the row `FOR UPDATE`, `core.write_plan_act_obligations` (the open obligations of earlier acts on the batch's pools), `core.requeue_plan_act_obligations` (the retried act's failed obligations of the types it still owns) and `core.record_plan_act_obligation_failure` | Two passes settling one obligation, and a settlement against a supersession or a requeue. A batch and a Retry take several rows in ascending `pool_id` order and touch disjoint rows: a batch supersedes the open rows of earlier acts on the types its act owns, a Retry requeues the failed rows of the types its act still owns, and both hold the shared rendezvous, so no default change can move the ownership while either runs. |
@@ -217,6 +217,17 @@ advisory lock, then the rest by branch.
`ApplySubscriptionCollection` reconcile, so it takes the locks above in the
order above.
- **Resume plan:** a member's Resume plan (`ResumeSubscription`) takes the
materialization rendezvous (shared), then the pool row, then reads what its
refusals need, confers the subscription's own tier over the grant, writes
the reconcile requests (inserts) and materializes the pool, in one
transaction. It takes no subscription advisory lock and makes no Stripe
call: every position write is under the pool row, the subscription lock
protects the subscription's own rows, which the act only reads, and the
outbox entry it wrote clears the pause after the commit. A reconcile of the
subscription waiting on the pool row finds the conferred provision live and
writes nothing.
No path takes a pool row and then a subscription's advisory lock, so the
order cannot wait in a cycle with a grant act, which takes the pool row and
never a subscription's lock.
+2 -2
View File
@@ -46,7 +46,7 @@ Database objects sit in the `core` schema; the baseline DDL is `internal/db/migr
| Default restoration | Resolves the org type's default ladder to its rank-0 product and confers it — plus the floor guard that restores only when the pool has no live occupancy on any ladder | `internal/entitlements/reapply_defaults.go` |
| Stripe-driven convergence | Translates a subscription's remote state into confer / end / suspend-resume calls, and leaves a product alone while another plan holds a ladder it took from the subscription (invariant 20; see the Payments and Billing card) | `internal/fulfillment/reconcile.go` |
| Member plan moves | Switch, cancel, and the commitment gating layered above conferral | `internal/fulfillment/plan_change.go` |
| Member position | The organization's position on each ladder, read from its default pool in one query (`ListLadderPositionsByPool`, `internal/entitlements/queries/pool_provision_ladders.sql`); the Products page, Checkout, the switch, fulfillment's plan changes, Pay invoice's plan route, the entitlements panel, the dashboard's empty state, and the organization page's Pools panel and Issue grant form read it. The query returns every live position with the position's and the subscription's status, active rows first, and "held" is what each reader makes of it. Every reader holds a suspended position as the organization's plan while it delivers nothing: the member surfaces show it as the current plan with its state, Checkout and the switch refuse it, Keep plan and the period-end cancellation act on it, and the organization page lists its delivery as suspended and warns before a grant supersedes it | `internal/entitlements/queries/pool_provision_ladders.sql` |
| Member position | The organization's position on each ladder, read from its default pool in one query (`ListLadderPositionsByPool`, `internal/entitlements/queries/pool_provision_ladders.sql`); the Products page, Checkout, the switch, fulfillment's plan changes, Pay invoice's plan route, the entitlements panel, the dashboard's empty state, and the organization page's Pools panel and Issue grant form read it. The query returns every live position with the position's and the subscription's status, active rows first, and "held" is what each reader makes of it. Every reader holds a suspended position as the organization's plan while it delivers nothing: the member surfaces show it as the current plan with its state, Checkout and the switch refuse it, Keep plan and the period-end cancellation act on it, and the organization page lists its delivery as suspended and warns before a grant supersedes it. The read also carries the end date of the grant that holds each position (`grant_valid_until`, the extension's after Manage), which the Products page's current card states | `internal/entitlements/queries/pool_provision_ladders.sql` |
| Member move offer | `plans.Offer` decides which move an organization is offered toward a price on a ladder, `plans.Across` combines a product's ladders, `plans.OffLadder` decides a product on none; the Products page draws from them and Checkout, the switch and its preview refuse anything else | `internal/plans/offer.go` |
| Operator ladder management | Ladder and tier CRUD, tier add, tier delete, tier removal and reorder. Each of the four tier changes is a plan act: one function in `internal/entitlements` runs it in one transaction under the exclusive materialization rendezvous and records it in the act record, a header and a detail row (the [Acts card](acts.md)). A reorder that gives a ladder a new rank-0 product also reaches every org type that defaults to the ladder: the act's listing then writes one obligation for each organization of those types that has a default pool and a record for each that has none, and the drain moves each pool onto the new rank-0 product (the Acts card describes both). The removal preview shows what happens to pools sitting on affected rank-0 tiers. Removing the last tier of a ladder an org type defaults to is refused on both paths, the delete of a tier no one holds (`DeleteTier`) and the removal of a held tier (`RemoveTier`), because both call `LastTierRefusal` and signups land on that ladder's entry tier | `internal/server/operator_plan_ladders.go`, `internal/entitlements/ladder_acts.go`, `internal/entitlements/tier_reorder.go`, `internal/entitlements/plan_act_runner.go` |
| Operator enrollment | Issue, revoke, and extend grants; revoke ends positions then restores the guarded default | `internal/server/operator_enrollment.go` |
@@ -77,7 +77,7 @@ An invariant is a rule the model guarantees everywhere; code that would break on
17. **[app]** Every plan act's transition reason comes from one function, `PlanActReason` (`internal/entitlements/plan_act_runner.go`), which builds it from the act's detail row so the wording lives in one place. The three tier acts that write transitions (add, removal, reorder) name the ladder: `tier added to ladder <name>`, `tier removal (<product> from <ladder>)` and `plan-ladder tier reorder (<ladder>)`. The default change names the org type's key: `org-type default change (<org type>)`. `DeleteTier` records its act (cause `tier_removed`) but writes no transition, because no one holds the tier, so it builds no reason; a Retry moves no position and has no reason. A move that a drain settles later builds the reason from the original act's detail row, so it reads as the act's own, and a migrated or grandfathered position appends what was done to it (`MigratedReason`, `GrandfatheredReason`). The reason is stored on the transition rows, so it is a write-time snapshot: it records the name as it read then, and a later rename does not rewrite it. An act that cannot read the ladder's row refuses at the read of names and records nothing.
18. **[app]** When a subscription's end, a grant's revocation or a grant's expiry ends at least one provision of that plan, every grant `core.resumable_grants(now())` names with that plan as holder re-confers from its own row, with the reason `grant-resumption:subscription:`, `grant-resumption:revocation:` or `grant-resumption:expiration:` followed by the ending plan's id. The chain rule: a marked grant waits on the nearest plan above it in its chain of replacements that has not ended, and resumes when that plan ends; a plan between them that ends first (it expires, is revoked, or is unmarked while it waits) is passed over. A grant and its extensions are one plan, named by the lineage's head. `core.grant_waits` is the one statement of the walk and the conditions (marked, active, valid at the instant, delivering nothing, its product on at least one ladder whose rungs no source but the holder holds, and first in resumption order among grants sharing a ladder), and the operator's delivery states and the member's held rung read it through the two functions. No other ending path resumes a grant: not a suspension, not a subscription dropping an item, not an extension, not an org-type migration and not a tier removal.
19. **[app]** On a ladder of the product it buys or switches to, a member's own move never takes a pool below a rung a grant delivers: from a grant-held rung the only move offered is up: through Checkout 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, unless the paid product or the target is a tier of another ladder too, 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.
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. The member's Resume plan supersedes a lower grant and is the one act that takes a ladder back for the product a grant displaced: when the grant ranks below the subscription's product, one transaction under the pool row confers that product from the subscription over the grant, and the reconcile that follows clears the pause. 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
+1 -1
View File
@@ -161,7 +161,7 @@ An operator can issue a grant to move a pool to a specific tier:
The system:
- Creates a grant with the chosen reason and the resumption mark, then calls the conferral boundary (`internal/entitlements/conferral.go`) through the operator handler in `internal/server/operator_enrollment.go`: it writes the grant decree, confers the position onto the organization's default pool, then materializes the organization's effective entitlements
- Registers a **Temporal workflow** to expire the grant at `valid_until` when one is set
- Keeps the ladder for a grant that supersedes a tier a subscription pays for: the subscription delivers nothing there while the grant lasts, and the console pauses its collection at Stripe, and the organization page's Billing card and the subscription's row in the operator's subscriptions list carry a **Not delivering** badge whose title reads "Billing paused while a grant holds {ladder}." once Stripe confirms the pause ("grants hold" when several grants hold the ladders) and "Billing continues while a grant holds {ladder}." until it does; the subscription delivers again in the act that ends the grant. The member sees the subscription's own card on the Products page: "Paused while {granted tier} is granted" once the pause is confirmed, with Cancel plan (the subscription ends at its period end and the grant stays), Keep plan while a cancellation or a downgrade is scheduled, and Pay invoice when its payment is due
- Keeps the ladder for a grant that supersedes a tier a subscription pays for: the subscription delivers nothing there while the grant lasts, and the console pauses its collection at Stripe, and the organization page's Billing card and the subscription's row in the operator's subscriptions list carry a **Not delivering** badge whose title reads "Billing paused while a grant holds {ladder}." once Stripe confirms the pause ("grants hold" when several grants hold the ladders) and "Billing continues while a grant holds {ladder}." until it does; the subscription delivers again in the act that ends the grant. The member sees the subscription's own card on the Products page: "Paused while {granted tier} is granted" once the pause is confirmed, with Resume plan when the grant ranks below the paid tier (the subscription takes the ladder back at its own price; nothing is charged now and the next renewal is charged as usual), Cancel plan (the subscription ends at its period end and the grant stays), Keep plan while a cancellation or a downgrade is scheduled, and Pay invoice when its payment is due. The granted tier's own card reads "Granted until {date}", or "Granted" for a grant with no end date, and under a grant of the subscription's own tier the paused line carries that date: "Paused while {granted tier} is granted until {date}".
While Temporal is not connected, which on a deployment without
`temporal-host` is always, a grant with **Valid until** is refused before
+2 -2
View File
@@ -211,8 +211,8 @@ A member moves between the paid tiers of a plan ladder on the Products page, and
- **Cancellation.** Every paid plan offers **Cancel plan** on its card. Access continues until the end of the paid period (`cancel_at_period_end`), after which the organization moves to its type's default plan, when one is configured, or leaves the ladder.
- **Taking a scheduled move back.** While a downgrade or a cancellation is scheduled, the plan's card offers **Keep plan**, which releases the schedule or clears `cancel_at_period_end` at no charge. While a downgrade is scheduled, **Cancel plan** follows it and replaces the downgrade; while a cancellation is scheduled, **Keep plan** is the only control.
- **A plan whose payment is due.** A subscription Stripe holds as `past_due` or `unpaid` keeps its card on the Products page, shown as the current plan with its state, while its entitlements are suspended. No switch is offered in either direction and a posted one is refused before any Stripe call, because a switch would invoice while the failed invoice is still open. **Keep plan** and **Cancel plan** at the period end work as on an active plan. A posted immediate cancellation is refused, because Stripe's immediate cancellation stops automatic collection of the customer's finalized invoices, and a **Keep plan** over a downgrade that Stripe has already applied is refused, because there is nothing left to take back. The unpaid invoice stays due after a cancellation: once the cancellation takes effect, Stripe stops retrying that subscription's open invoice and leaves the customer's other subscriptions collecting, so the member pays it through the invoice's **Pay invoice**. A paused subscription shows its state and is refused every act; only a subscription made outside the console reaches `paused`, which follows a trial that ends without a payment method, because the console's Checkout creates no trial.
- **A grant over a paid plan.** An operator's grant over a tier a subscription pays for keeps the ladder until it ends or is revoked, and the subscription delivers nothing there. The console pauses the subscription's collection at Stripe (`pause_collection` with `behavior=void`, and the marker `member_console_collection=paused_for_grant` in the subscription's metadata) within seconds, through the outbox, and sets no `resumes_at`. Each renewal Stripe makes while the grant holds is a draft for about an hour and is then voided, so nothing is charged. When the grant ends, the tier is delivered again in the act that ends the grant, and the console clears the pause and the marker within seconds; no money moves at either edge. While the grant holds the ladder, the organization page's Billing card and the subscription's row in the operator's subscriptions list carry a **Not delivering** badge. Once Stripe confirms the pause, which the subscription row records, its title reads "Billing paused while a grant holds {ladder}." ("grants hold" when several grants hold the ladders); until it does, and while the pause has failed, it reads "Billing continues while a grant holds {ladder}." When the ladder is left to the default or to nothing in another way, such as a tier removal, the subscription's next reconcile delivers its tier again. The member's Products page draws the card of the tier the subscription pays for with its own price and its pause as the subscription row records it: "Paused while {granted} is granted" once Stripe confirmed the pause, and the ordinary "{price}, renews {date}" line until it has. That card offers Cancel plan, which sets `cancel_at_period_end` on the subscription and leaves the pause as it is, so the subscription ends at its period end with no final invoice and the grant keeps the ladder; Keep plan, which clears that flag and any scheduled downgrade and likewise sends no pause change; and, for a subscription whose payment is due, Pay invoice, which opens the hosted page of the subscription's open invoice rather than its latest invoice, because the latest invoice is a renewal voided while the grant holds. If the grant ends before the cancellation date, the subscription delivers its tier until its period ends. A new purchase over a grant still replaces the grant. A switch to the granted tier or above ends the grant's hold, and the subscription delivers the new tier. What this accepts until the exact money rule (#280): the renewal date decides which way a grant that is not whole periods falls, because a period's renewal is voided or charged whole; a pause landing after a renewal's finalization lets that renewal be charged, and a clear landing before the finalization of a renewal made while paused lets it be charged; a pause or clear that lands late moves a whole period one way or the other, and a void cannot be undone; the first charge after a grant can come without Stripe's upcoming-renewal reminder. A subscription item with no entitlement set, such as a donation line added in the Dashboard, stops billing with the tier beside it while the pause holds. An invoice open before the grant stays due: Stripe keeps retrying it, and the account's dunning setting can cancel the paused subscription.
- **The switch from a paused plan.** A subscription the console paused for a grant switches to a tier at or above the grant (the granted tier's own card offers **Upgrade**) and above the paid tier, unless the paid tier is also a tier of another ladder, which is offered no switch, because the switch would move the subscription off that ladder, and a target that is a tier of another ladder too is offered none, because its conferral would supersede whatever holds that ladder, a grant included. Stripe refuses an `always_invoice` update on a paused subscription, and it refuses `pause_collection` beside `payment_behavior=pending_if_incomplete`, which the approval path needs, so the switch is two calls: the console clears the pause and its marker, then sends the upgrade unchanged. The confirmation's second paragraph opens "Your subscription replaces the grant.", and once the subscription's row records the pause it states no figure, because Stripe answers no proration preview for a paused subscription. When the card declines, the payment needs approval, or any call fails, the console pauses the subscription again at once, through the same reconcile that decides every pause, and an outbox entry written before the clear and due four minutes later does so if the process stops between the calls. The switch refuses with "Try again in a moment." while a paused renewal is about to finalize, about the last ten minutes of the renewal's draft hour, and, in live mode, for as long as the endpoint's `invoice.created` deliveries fail, up to 72 hours, because Stripe holds a renewal's finalization until they succeed. Cancel plan voids the open invoice of a switch the member left awaiting approval before it sets `cancel_at_period_end`, because paying that invoice later would apply the switch on a plan the member chose to end. A pause made in the Dashboard refuses the switch with the generic failure, and the console leaves that pause as its maker left it. A switch toward a price of another interval is offered and its confirmation reads "Your card is charged {target price} now, minus a credit for the unused part of your current plan, then {then} from today.", because Stripe then charges the new price's full period less the unused time and moves the billing date to the switch. A tier priced in another currency than the subscription's is never offered, because Stripe refuses a change of a subscription's currency. What this accepts until the exact money rule (#280): the switch's proration credits the old price's unused time for a period whose renewal was voided, so the member pays the price difference, not the new price, for the rest of that period (toward a price of another interval, the new period less that credit); a renewal that Stripe finalizes inside the unpaused window despite the guard, because it finalizes earlier than the later of the draft's two times or because the restore is late, is charged in full on a declined or approval-pending attempt, and fails and opens dunning on a declining card; a switch applied inside a paused renewal's draft hour also pays that renewal at the old price in full, on top of the proration, and when that charge fails the subscription that replaced the grant goes past due and its tier is suspended; an approval paid after the operator changed what holds the ladder or how the tiers rank takes the ladder only when the new tier ranks at or above the holder then, any member of the organization can pay the abandoned approval's invoice from the Billing page without reading the confirmation, and a lost apply event delays the paid tier up to one period; and after a paid approval the subscription delivers for about 15 seconds while Stripe still pauses it, so a renewal finalized then is voided at the operator's cost.
- **A grant over a paid plan.** An operator's grant over a tier a subscription pays for keeps the ladder until it ends or is revoked, and the subscription delivers nothing there. The console pauses the subscription's collection at Stripe (`pause_collection` with `behavior=void`, and the marker `member_console_collection=paused_for_grant` in the subscription's metadata) within seconds, through the outbox, and sets no `resumes_at`. Each renewal Stripe makes while the grant holds is a draft for about an hour and is then voided, so nothing is charged. When the grant ends, the tier is delivered again in the act that ends the grant, and the console clears the pause and the marker within seconds; no money moves at either edge. While the grant holds the ladder, the organization page's Billing card and the subscription's row in the operator's subscriptions list carry a **Not delivering** badge. Once Stripe confirms the pause, which the subscription row records, its title reads "Billing paused while a grant holds {ladder}." ("grants hold" when several grants hold the ladders); until it does, and while the pause has failed, it reads "Billing continues while a grant holds {ladder}." When the ladder is left to the default or to nothing in another way, such as a tier removal, the subscription's next reconcile delivers its tier again. The member's Products page draws the card of the tier the subscription pays for with its own price and its pause as the subscription row records it: "Paused while {granted} is granted" once Stripe confirmed the pause, and the ordinary "{price}, renews {date}" line until it has. That card offers Cancel plan, which sets `cancel_at_period_end` on the subscription and leaves the pause as it is, so the subscription ends at its period end with no final invoice and the grant keeps the ladder; Keep plan, which clears that flag and any scheduled downgrade and likewise sends no pause change; and, for a subscription whose payment is due, Pay invoice, which opens the hosted page of the subscription's open invoice rather than its latest invoice, because the latest invoice is a renewal voided while the grant holds. If the grant ends before the cancellation date, the subscription delivers its tier until its period ends. A new purchase over a grant still replaces the grant. A switch to the granted tier or above ends the grant's hold, and the subscription delivers the new tier. Under a grant ranked below the paid tier the paid tier's card also offers **Resume plan**, before Cancel plan: the member's request confers the subscription's own tier over the grant, which the grant's end would do, and makes no Stripe call; the console then clears the pause and the marker through the outbox, and nothing is charged until the next renewal, which is charged at the subscription's own price. The rest of a period whose renewal was voided is therefore free, up to one whole period (nearly a year on an annual price), at a moment the member picks; this is the end edge's cost, accepted until the exact money rule (#280). Resume plan is not offered while the pause is not yet confirmed, while a cancellation or a downgrade is scheduled, while the payment is due or Stripe has paused the subscription, for a pause made outside the console, or for a tier that belongs to another ladder too. What this accepts until the exact money rule (#280): the renewal date decides which way a grant that is not whole periods falls, because a period's renewal is voided or charged whole; a pause landing after a renewal's finalization lets that renewal be charged, and a clear landing before the finalization of a renewal made while paused lets it be charged; a pause or clear that lands late moves a whole period one way or the other, and a void cannot be undone; the first charge after a grant can come without Stripe's upcoming-renewal reminder. A subscription item with no entitlement set, such as a donation line added in the Dashboard, stops billing with the tier beside it while the pause holds. An invoice open before the grant stays due: Stripe keeps retrying it, and the account's dunning setting can cancel the paused subscription.
- **The switch from a paused plan.** A subscription the console paused for a grant switches to a tier at or above the grant (the granted tier's own card offers **Pay for this plan**, the tiers above it **Upgrade**) and above the paid tier, unless the paid tier is also a tier of another ladder, which is offered no switch, because the switch would move the subscription off that ladder, and a target that is a tier of another ladder too is offered none, because its conferral would supersede whatever holds that ladder, a grant included. Stripe refuses an `always_invoice` update on a paused subscription, and it refuses `pause_collection` beside `payment_behavior=pending_if_incomplete`, which the approval path needs, so the switch is two calls: the console clears the pause and its marker, then sends the upgrade unchanged. The confirmation's heading reads "Pay for {tier}?" on the granted tier's own card and "Switch to {tier}?" on the others, and its second paragraph opens "Your subscription replaces the grant.", and once the subscription's row records the pause it states no figure, because Stripe answers no proration preview for a paused subscription. When the card declines, the payment needs approval, or any call fails, the console pauses the subscription again at once, through the same reconcile that decides every pause, and an outbox entry written before the clear and due four minutes later does so if the process stops between the calls. The switch refuses with "Try again in a moment." while a paused renewal is about to finalize, about the last ten minutes of the renewal's draft hour, and, in live mode, for as long as the endpoint's `invoice.created` deliveries fail, up to 72 hours, because Stripe holds a renewal's finalization until they succeed. Cancel plan voids the open invoice of a switch the member left awaiting approval before it sets `cancel_at_period_end`, because paying that invoice later would apply the switch on a plan the member chose to end. A pause made in the Dashboard refuses the switch with the generic failure, and the console leaves that pause as its maker left it. A switch toward a price of another interval is offered and its confirmation reads "Your card is charged {target price} now, minus a credit for the unused part of your current plan, then {then} from today.", because Stripe then charges the new price's full period less the unused time and moves the billing date to the switch. A tier priced in another currency than the subscription's is never offered, because Stripe refuses a change of a subscription's currency. What this accepts until the exact money rule (#280): the switch's proration credits the old price's unused time for a period whose renewal was voided, so the member pays the price difference, not the new price, for the rest of that period (toward a price of another interval, the new period less that credit); a renewal that Stripe finalizes inside the unpaused window despite the guard, because it finalizes earlier than the later of the draft's two times or because the restore is late, is charged in full on a declined or approval-pending attempt, and fails and opens dunning on a declining card; a switch applied inside a paused renewal's draft hour also pays that renewal at the old price in full, on top of the proration, and when that charge fails the subscription that replaced the grant goes past due and its tier is suspended; an approval paid after the operator changed what holds the ladder or how the tiers rank takes the ladder only when the new tier ranks at or above the holder then, any member of the organization can pay the abandoned approval's invoice from the Billing page without reading the confirmation, and a lost apply event delays the paid tier up to one period; and after a paid approval the subscription delivers for about 15 seconds while Stripe still pauses it, so a renewal finalized then is voided at the operator's cost.
- **Pay invoice.** The card of a plan whose payment is due, and the detail of an open invoice, link to Stripe's hosted invoice page. The console reads the page's URL from Stripe when the member opens the link and redirects there; it never stores or logs the URL, which is a bearer link, and it needs no key permission beyond the reads of subscriptions and invoices that the reconciles already make. Paying there makes the new card the customer's default for invoices but leaves the subscription's own default on the card that failed, which Stripe charges first, so the next renewal charges the failed card again. Stripe's page still takes payment for an invoice Stripe has marked uncollectible, which the console shows as open until the invoice sweep reads it again.
A restricted key needs permission for the calls these moves make, beyond those the console already uses: `POST /v1/subscription_schedules`, `POST /v1/subscription_schedules/{id}`, `POST /v1/subscription_schedules/{id}/release` and `POST /v1/invoices/{id}/void`. The paused switch also lists a subscription's draft invoices with `GET /v1/invoices`, among the reads the key already holds for the invoice events. The console also updates a subscription with `POST /v1/subscriptions/{id}` to set `pause_collection` while a grant holds its ladder and to clear it when the grant no longer does, which is the Subscriptions write the plan moves already need. Stripe names the missing permission in the error a key without it receives.
@@ -72,17 +72,24 @@
payment due draws no suspension fact line; Stripe's own
"paused" status keeps its line and its fact line, and no
control. Controls: Pay invoice when the payment is due, then
Keep plan while a move is scheduled, then Cancel plan unless a
cancellation is. */}}
Keep plan while a move is scheduled, then Resume plan when the
offer decision allows it (a grant ranked below the paid tier,
design D20), then Cancel plan unless a cancellation is.
The current card a grant holds states the grant's term: "Granted
until {date}", or "Granted" for a grant with no end date
(design D21). The paid card under a grant of its own tier
states it inside its paused line, "until {date}" right after
"is granted". */}}
{{ if or (eq .Relation "current") .PaidUnderGrant }}
{{ if .BillingPending }}
<p class="text-muted small mb-2">Billing details are still being processed.</p>
{{ else if and .PaidUnderGrant .CollectionPaused .MovesTo }}
<p class="text-muted small mb-2">Paused while {{ .GrantedName }} is granted, moves to {{ .MovesTo }} on {{ .MovesOn }}</p>
<p class="text-muted small mb-2">Paused while {{ .GrantedName }} is granted{{ if .GrantedUntil }} until {{ .GrantedUntil }}{{ end }}, moves to {{ .MovesTo }} on {{ .MovesOn }}</p>
{{ else if and .PaidUnderGrant .CollectionPaused (eq .Suspension "payment_due") }}
<p class="text-muted small mb-2">Paused while {{ .GrantedName }} is granted, payment due</p>
<p class="text-muted small mb-2">Paused while {{ .GrantedName }} is granted{{ if .GrantedUntil }} until {{ .GrantedUntil }}{{ end }}, payment due</p>
{{ else if and .PaidUnderGrant .CollectionPaused }}
<p class="text-muted small mb-2">Paused while {{ .GrantedName }} is granted</p>
<p class="text-muted small mb-2">Paused while {{ .GrantedName }} is granted{{ if .GrantedUntil }} until {{ .GrantedUntil }}{{ end }}</p>
{{ else if .MovesTo }}
<p class="text-muted small mb-2">{{ .BillingPriceText }}, moves to {{ .MovesTo }} on {{ .MovesOn }}</p>
{{ else if .EndsOn }}
@@ -93,6 +100,10 @@
<p class="text-muted small mb-2">{{ .BillingPriceText }}, paused</p>
{{ else if .BillingPriceText }}
<p class="text-muted small mb-2">{{ .BillingPriceText }}, renews {{ .BillingRenewsOn }}</p>
{{ else if .GrantedUntil }}
<p class="text-muted small mb-2">Granted until {{ .GrantedUntil }}</p>
{{ else if .Granted }}
<p class="text-muted small mb-2">Granted</p>
{{ end }}
{{- if and .Suspension (or (not .PaidUnderGrant) (eq .Suspension "paused")) }}
<p class="small text-danger mb-2">{{ if eq .Suspension "paused" }}Entitlements are suspended.{{ else }}Entitlements are suspended until the invoice is paid.{{ end }}</p>
@@ -133,8 +144,21 @@
hx-post="/partials/member/plans/keep" hx-vals='{"ladder_id": "{{ .LadderID }}"}'
hx-target="#member-plans" hx-swap="innerHTML">Keep plan</button>
{{ end }}
{{- if .CanResume }}
<button type="button" class="btn btn-outline-primary btn-sm w-100 {{ if or $pay .CanKeep }}mt-2{{ else }}mt-auto{{ end }}"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/member/plans/resume"
data-action-method="post"
data-action-target="#member-plans"
data-action-title="{{ .ResumeTitle }}"
data-action-body="{{ .ResumeBody }}"
data-action-confirm-label="Resume plan"
data-action-style="primary"
data-action-fields='{"ladder_id": "{{ .LadderID }}"}'{{ if $.BillingFacts.Shown }} aria-describedby="{{ $.BillingFacts.ID }}"{{ end }}>Resume plan</button>
{{- end }}
{{ if .CanCancel }}
<button type="button" class="btn btn-outline-secondary btn-sm w-100{{ if or $pay .CanKeep }} mt-2{{ else }} mt-auto{{ end }}"
<button type="button" class="btn btn-outline-secondary btn-sm w-100{{ if or $pay .CanKeep .CanResume }} mt-2{{ else }} mt-auto{{ end }}"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/member/plans/cancel"
@@ -193,3 +193,50 @@ func checkNothingDelivered(t *testing.T, d *sql.DB, poolID, ladderID string) {
t.Errorf("the suspended plan's position_status = %q (position present: %v), want \"suspended\"", got.PositionStatus, ok)
}
}
// TestListLadderPositionsByPoolReadsTheHoldingGrantsEnd: the position row
// carries the end date of the grant whose provision holds it, the extension's
// after Manage, and NULL for an open-ended grant, the organization type's
// default and a subscription (grant-pauses-subscription-billing D21).
func TestListLadderPositionsByPoolReadsTheHoldingGrantsEnd(t *testing.T) {
d := dbtest.Open(t)
w := world.New(t, d)
basic, plus := w.Product("Basic"), w.Product("Plus")
w.Price(plus, 1200)
ladder := w.Ladder("Ends", basic, plus)
orgType := w.OrgType("ends")
w.DefaultLadder(orgType, ladder)
until := time.Date(2099, time.February, 20, 12, 0, 0, 0, time.UTC)
extended := time.Date(2099, time.March, 20, 12, 0, 0, 0, time.UTC)
rows := []struct {
name string
build func(o world.Org)
want sql.NullTime
}{
{"a grant with an end date", func(o world.Org) { w.Issue(o, plus, world.IssueOpts{ValidUntil: until}) },
sql.NullTime{Time: until, Valid: true}},
{"an open-ended grant", func(o world.Org) { w.Issue(o, plus, world.IssueOpts{}) }, sql.NullTime{}},
{"an extended grant", func(o world.Org) { w.Extend(w.Issue(o, plus, world.IssueOpts{ValidUntil: until}), extended) },
sql.NullTime{Time: extended, Valid: true}},
{"the organization type's default", func(o world.Org) { w.Floor(o) }, sql.NullTime{}},
{"a subscription", func(o world.Org) { w.Subscribe(o, world.SubOpts{}, plus) }, sql.NullTime{}},
}
owner := w.Person("Ann")
for _, row := range rows {
o := w.Org("Ends", orgType, owner)
row.build(o)
positions, err := entitlements.New(d).ListLadderPositionsByPool(t.Context(), o.PoolID)
if err != nil {
t.Fatalf("%s: ListLadderPositionsByPool: %v", row.name, err)
}
pos, ok := entitlements.PositionsByLadder(positions)[ladder.ID]
if !ok {
t.Errorf("%s: no position on the ladder", row.name)
continue
}
if pos.GrantValidUntil.Valid != row.want.Valid || !pos.GrantValidUntil.Time.Equal(row.want.Time) {
t.Errorf("%s: grant_valid_until = %v, want %v", row.name, pos.GrantValidUntil, row.want)
}
}
}
@@ -390,6 +390,7 @@ SELECT l.plan_ladder_id, l.pool_id, l.product_id, l.provision_id, l.activated_at
l.status AS position_status, l.default_sourced,
pd.name AS product_name,
p.subscription_id, p.grant_id, p.purchase_id,
g.valid_until AS grant_valid_until,
s.subscription_id AS projected_subscription_id,
s.status AS subscription_status,
s.current_period_end, s.cancel_at_period_end,
@@ -400,6 +401,7 @@ SELECT l.plan_ladder_id, l.pool_id, l.product_id, l.provision_id, l.activated_at
FROM core.pool_provision_ladders l
JOIN core.pool_provisions p ON p.provision_id = l.provision_id
JOIN core.products pd ON pd.product_id = l.product_id
LEFT JOIN core.grants g ON g.grant_id = p.grant_id
LEFT JOIN core.subscriptions s ON s.subscription_id = p.subscription_id
LEFT JOIN core.subscription_scheduled_changes c
ON c.scheduled_change_id = (
@@ -432,6 +434,7 @@ type ListLadderPositionsByPoolRow struct {
SubscriptionID uuid.NullUUID `json:"subscription_id"`
GrantID uuid.NullUUID `json:"grant_id"`
PurchaseID uuid.NullUUID `json:"purchase_id"`
GrantValidUntil sql.NullTime `json:"grant_valid_until"`
ProjectedSubscriptionID uuid.NullUUID `json:"projected_subscription_id"`
SubscriptionStatus sql.NullString `json:"subscription_status"`
CurrentPeriodEnd sql.NullTime `json:"current_period_end"`
@@ -465,6 +468,11 @@ type ListLadderPositionsByPoolRow struct {
// grant row with grant_reason 'default'; default_sourced tells the two apart
// (grant-pauses-subscription-billing D9).
//
// grant_valid_until is the end date of the grant whose provision holds the
// position, the extension's after Manage, NULL for an open-ended grant and for
// a position no grant holds; the Products page's current card reads it
// (grant-pauses-subscription-billing D21).
//
// 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
@@ -493,6 +501,7 @@ func (q *Queries) ListLadderPositionsByPool(ctx context.Context, poolID string)
&i.SubscriptionID,
&i.GrantID,
&i.PurchaseID,
&i.GrantValidUntil,
&i.ProjectedSubscriptionID,
&i.SubscriptionStatus,
&i.CurrentPeriodEnd,
+11
View File
@@ -222,6 +222,12 @@ type Querier interface {
// is the changed rule's, where the key is the rule's), so the key comes back
// with it. The drain reads the pair once per change.
GetRuleChangeStamp(ctx context.Context, changeID string) (GetRuleChangeStampRow, error)
// What Resume plan refuses on, read under the pool row
// (grant-pauses-subscription-billing D20): the subscription's Stripe status,
// whether its cancellation is scheduled at the period end, the console's pause
// as the row records it (the behavior and the marker), and whether a scheduled
// change of it (a downgrade or a cancellation) is still to apply.
GetSubscriptionResumeFacts(ctx context.Context, subscriptionID string) (GetSubscriptionResumeFactsRow, error)
GetUsageByPoolAndResource(ctx context.Context, arg GetUsageByPoolAndResourceParams) (NumericEntitlementUsage, error)
// Whether the act settled any obligation on a type it owns as migrated or
// grandfathered, the two outcomes that end default-sourced positions and so
@@ -428,6 +434,11 @@ type Querier interface {
// grant row with grant_reason 'default'; default_sourced tells the two apart
// (grant-pauses-subscription-billing D9).
//
// grant_valid_until is the end date of the grant whose provision holds the
// position, the extension's after Manage, NULL for an open-ended grant and for
// a position no grant holds; the Products page's current card reads it
// (grant-pauses-subscription-billing D21).
//
// 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
@@ -206,6 +206,11 @@ GROUP BY product_id;
-- grant row with grant_reason 'default'; default_sourced tells the two apart
-- (grant-pauses-subscription-billing D9).
--
-- grant_valid_until is the end date of the grant whose provision holds the
-- position, the extension's after Manage, NULL for an open-ended grant and for
-- a position no grant holds; the Products page's current card reads it
-- (grant-pauses-subscription-billing D21).
--
-- 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
@@ -217,6 +222,7 @@ SELECT l.plan_ladder_id, l.pool_id, l.product_id, l.provision_id, l.activated_at
l.status AS position_status, l.default_sourced,
pd.name AS product_name,
p.subscription_id, p.grant_id, p.purchase_id,
g.valid_until AS grant_valid_until,
s.subscription_id AS projected_subscription_id,
s.status AS subscription_status,
s.current_period_end, s.cancel_at_period_end,
@@ -227,6 +233,7 @@ SELECT l.plan_ladder_id, l.pool_id, l.product_id, l.provision_id, l.activated_at
FROM core.pool_provision_ladders l
JOIN core.pool_provisions p ON p.provision_id = l.provision_id
JOIN core.products pd ON pd.product_id = l.product_id
LEFT JOIN core.grants g ON g.grant_id = p.grant_id
LEFT JOIN core.subscriptions s ON s.subscription_id = p.subscription_id
LEFT JOIN core.subscription_scheduled_changes c
ON c.scheduled_change_id = (
@@ -0,0 +1,16 @@
-- name: GetSubscriptionResumeFacts :one
-- What Resume plan refuses on, read under the pool row
-- (grant-pauses-subscription-billing D20): the subscription's Stripe status,
-- whether its cancellation is scheduled at the period end, the console's pause
-- as the row records it (the behavior and the marker), and whether a scheduled
-- change of it (a downgrade or a cancellation) is still to apply.
SELECT s.status::text AS status,
s.cancel_at_period_end,
s.pause_collection_behavior,
s.pause_collection_marked,
EXISTS (
SELECT 1 FROM core.subscription_scheduled_changes c
WHERE c.subscription_id = s.subscription_id AND c.status = 'scheduled'
) AS change_scheduled
FROM core.subscriptions s
WHERE s.subscription_id = sqlc.arg(subscription_id)::uuid;
@@ -0,0 +1,263 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package entitlements
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
// Resume plan (openspec change grant-pauses-subscription-billing, design D20).
// A subscription whose tier ranks above the grant that holds its ladder takes
// the ladder back, for its own tier at its own price, when the member asks.
// No reconcile can carry it: the subscription's product is the product the
// grant displaced, which the displacement rule never lets take the ladder
// back, so this act confers it.
// Refusals of ResumeSubscription. The caller (fulfillment's ResumePlan) maps
// each to the member's existing sentence; the decision refuses every one
// before the act runs, so they answer only a race.
var (
// ErrSubscriptionHoldsLadder: the subscription already holds a live
// provision on the ladder, as after a second submit or an ending act that
// delivered it first.
ErrSubscriptionHoldsLadder = errors.New("the subscription already holds the ladder")
// ErrResumeSuspended: the subscription's status is past_due or unpaid.
ErrResumeSuspended = errors.New("the subscription's payment is due")
// ErrResumePaused: the subscription's status is paused.
ErrResumePaused = errors.New("the subscription is paused")
// ErrResumeRefused: any other reason the resume is not allowed (status,
// scheduled move, no console pause, no grant ranked below the product, a
// bundle, a draft product).
ErrResumeRefused = errors.New("the subscription cannot resume the ladder")
)
// ResumeSubscriptionInput names the subscription that takes the ladder back
// and the transition's reason ("member:resume").
type ResumeSubscriptionInput struct {
PoolID string
LadderID string
SubscriptionID string
Reason string
}
// resumeFacts is everything the refusals read, gathered under the pool row.
type resumeFacts struct {
status string
cancelAtEnd bool
changeScheduled bool
pauseBehavior sql.NullString
pauseMarked bool
holdsLadder bool
product string
quantity int32
lifecycle string
otherLadders bool
holderBelow bool
}
// ResumeSubscription confers the subscription's own tier over the grant that
// holds the ladder, in one materializing transaction: the pool row first,
// then every read the refusals need, each refusal before any write, the
// conferral (actor system, the summed quantity), the reconcile requests of
// every live subscription of the organization (the act changes who holds a
// ladder), and the pool's materialization, which BeginMaterializing does not
// do at commit. core.confer supersedes the grant's position as an upgrade and
// core.grant_replacements then pairs the grant with the subscription.
//
// It takes no subscription advisory lock, for the reason the ending acts'
// delivery gives: every position write is under the pool row, and the
// subscription lock protects the subscription's own rows, which the act only
// reads. A reconcile of the subscription waiting on the pool row then finds
// the conferred provision live and writes nothing. It makes no Stripe call:
// the outbox entry it writes clears the pause after the commit, so a crash
// between the two loses nothing and a failed clear is retried, dead-lettered
// and swept like every clear. No path takes a pool row and then a
// subscription's advisory lock (docs/database-locks.md).
func ResumeSubscription(ctx context.Context, database *sql.DB, in ResumeSubscriptionInput) error {
tx, err := BeginMaterializing(ctx, database)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback()
q := New(tx)
if _, err := q.LockPools(ctx, in.PoolID); err != nil {
return fmt.Errorf("lock pool %s: %w", in.PoolID, err)
}
f, err := readResumeFacts(ctx, q, in)
if err != nil {
return err
}
if err := f.refusal(); err != nil {
return err
}
subID := uuid.NullUUID{UUID: uuid.MustParse(in.SubscriptionID), Valid: true}
if _, _, err := q.Confer(ctx, ConferParams{
PoolID: in.PoolID,
ProductID: f.product,
SubscriptionID: subID,
Quantity: f.quantity,
ActorType: "system",
Reason: sql.NullString{String: in.Reason, Valid: in.Reason != ""},
}); err != nil {
return fmt.Errorf("confer product %s for subscription %s: %w", f.product, in.SubscriptionID, err)
}
if err := RequestPoolSubscriptionReconciles(ctx, tx, in.PoolID, ""); err != nil {
return err
}
if err := MaterializePoolEntitlements(ctx, q, in.PoolID); err != nil {
return fmt.Errorf("materialize entitlements: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}
// readResumeFacts reads the subscription's row, its recorded products, its
// live provisions, the product's shape and lifecycle, and its displacements.
// A subscription with no row is a refusal.
func readResumeFacts(ctx context.Context, q *Queries, in ResumeSubscriptionInput) (resumeFacts, error) {
var f resumeFacts
row, err := q.GetSubscriptionResumeFacts(ctx, in.SubscriptionID)
if errors.Is(err, sql.ErrNoRows) {
return f, fmt.Errorf("%w: no subscription %s", ErrResumeRefused, in.SubscriptionID)
}
if err != nil {
return f, fmt.Errorf("read the resume facts of subscription %s: %w", in.SubscriptionID, err)
}
f.status = row.Status
f.cancelAtEnd = row.CancelAtPeriodEnd
f.changeScheduled = row.ChangeScheduled
f.pauseBehavior = row.PauseCollectionBehavior
f.pauseMarked = row.PauseCollectionMarked
recorded, err := q.ListRecordedSubscriptionProductsOfPool(ctx, in.PoolID)
if err != nil {
return f, fmt.Errorf("read the recorded products of pool %s: %w", in.PoolID, err)
}
live, err := q.GetLivePoolProvisionsBySubscriptionID(ctx, uuid.NullUUID{UUID: uuid.MustParse(in.SubscriptionID), Valid: true})
if err != nil {
return f, fmt.Errorf("list the live provisions of subscription %s: %w", in.SubscriptionID, err)
}
products := map[string]bool{}
var mine []ListRecordedSubscriptionProductsOfPoolRow
for _, r := range recorded {
if r.SubscriptionID == in.SubscriptionID {
mine = append(mine, r)
products[r.ProductID] = true
}
}
for _, p := range live {
products[p.ProductID] = true
}
ids := make([]string, 0, len(products))
for id := range products {
ids = append(ids, id)
}
shapes, err := q.ListConferralShapes(ctx, ids)
if err != nil {
return f, fmt.Errorf("list the conferral shapes: %w", err)
}
rungs := RungsByProduct(shapes)
for _, p := range live {
if onLadder(rungs[p.ProductID], in.LadderID) {
f.holdsLadder = true
}
}
f.chooseProduct(mine, rungs, in.LadderID)
return f, f.readProductFacts(ctx, q, in, rungs)
}
// onLadder reports whether one of the rungs is on the ladder.
func onLadder(rungs []Rung, ladderID string) bool {
for _, r := range rungs {
if r.LadderID == ladderID {
return true
}
}
return false
}
// chooseProduct sets the subscription's one recorded product that is a tier
// of the ladder, with its summed quantity. A subscription that records none,
// or more than one, leaves product empty, which refusal reports.
func (f *resumeFacts) chooseProduct(mine []ListRecordedSubscriptionProductsOfPoolRow, rungs map[string][]Rung, ladderID string) {
count := 0
for _, r := range mine {
if !onLadder(rungs[r.ProductID], ladderID) {
continue
}
count++
f.product, f.quantity = r.ProductID, r.Quantity
f.otherLadders = len(rungs[r.ProductID]) > 1
}
if count != 1 {
f.product = ""
}
}
// readProductFacts reads the chosen product's lifecycle and whether the
// ladder's holder is a grant ranked below it.
func (f *resumeFacts) readProductFacts(ctx context.Context, q *Queries, in ResumeSubscriptionInput, rungs map[string][]Rung) error {
if f.product == "" {
return nil
}
lifecycle, err := q.GetProductLifecycleStatus(ctx, f.product)
if err != nil {
return fmt.Errorf("read the lifecycle of product %s: %w", f.product, err)
}
f.lifecycle = lifecycle
rows, err := q.ListSubscriptionDisplacements(ctx, ListSubscriptionDisplacementsParams{
SubscriptionID: in.SubscriptionID,
PoolID: in.PoolID,
})
if err != nil {
return fmt.Errorf("list the displacements of subscription %s: %w", in.SubscriptionID, err)
}
var rank int32
for _, r := range rungs[f.product] {
if r.LadderID == in.LadderID {
rank = r.Rank
}
}
for _, d := range DisplacementsFromRows(rows) {
if d.LadderID == in.LadderID && d.HolderSource == "grant" && d.HolderRanked && d.HolderRank < rank {
f.holderBelow = true
}
}
return nil
}
// refusal answers the first refusal of design D20's table, in its order,
// except that the holder's rank, which needs the chosen product, is tested
// after the product's own refusals, or nil.
func (f resumeFacts) refusal() error {
switch {
case f.holdsLadder:
return ErrSubscriptionHoldsLadder
case f.status == "past_due" || f.status == "unpaid":
return ErrResumeSuspended
case f.status == "paused":
return ErrResumePaused
case f.status != "active" && f.status != "trialing":
return fmt.Errorf("%w: status %s", ErrResumeRefused, f.status)
case f.cancelAtEnd || f.changeScheduled:
return fmt.Errorf("%w: a cancellation or a downgrade is scheduled", ErrResumeRefused)
case !f.pauseBehavior.Valid || !f.pauseMarked:
return fmt.Errorf("%w: the console recorded no pause", ErrResumeRefused)
case f.product == "" || f.otherLadders:
return fmt.Errorf("%w: the subscription records no single product of only this ladder", ErrResumeRefused)
case f.lifecycle == "draft":
return fmt.Errorf("%w: the product is a draft", ErrResumeRefused)
case !f.holderBelow:
return fmt.Errorf("%w: no grant ranked below the product holds the ladder", ErrResumeRefused)
}
return nil
}
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: subscription_resume.sql
package entitlements
import (
"context"
"database/sql"
)
const getSubscriptionResumeFacts = `-- name: GetSubscriptionResumeFacts :one
SELECT s.status::text AS status,
s.cancel_at_period_end,
s.pause_collection_behavior,
s.pause_collection_marked,
EXISTS (
SELECT 1 FROM core.subscription_scheduled_changes c
WHERE c.subscription_id = s.subscription_id AND c.status = 'scheduled'
) AS change_scheduled
FROM core.subscriptions s
WHERE s.subscription_id = $1::uuid
`
type GetSubscriptionResumeFactsRow struct {
Status string `json:"status"`
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
PauseCollectionBehavior sql.NullString `json:"pause_collection_behavior"`
PauseCollectionMarked bool `json:"pause_collection_marked"`
ChangeScheduled bool `json:"change_scheduled"`
}
// What Resume plan refuses on, read under the pool row
// (grant-pauses-subscription-billing D20): the subscription's Stripe status,
// whether its cancellation is scheduled at the period end, the console's pause
// as the row records it (the behavior and the marker), and whether a scheduled
// change of it (a downgrade or a cancellation) is still to apply.
func (q *Queries) GetSubscriptionResumeFacts(ctx context.Context, subscriptionID string) (GetSubscriptionResumeFactsRow, error) {
row := q.db.QueryRowContext(ctx, getSubscriptionResumeFacts, subscriptionID)
var i GetSubscriptionResumeFactsRow
err := row.Scan(
&i.Status,
&i.CancelAtPeriodEnd,
&i.PauseCollectionBehavior,
&i.PauseCollectionMarked,
&i.ChangeScheduled,
)
return i, err
}
@@ -0,0 +1,382 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package entitlements_test
// Resume plan (openspec change grant-pauses-subscription-billing, design D20):
// the subscription whose tier ranks above the grant that holds its ladder takes
// the ladder back in one transaction under the pool row. The world is a ladder
// of Basic, Plus, Pro and Max, each with a limit of its rank's number of
// sites, so the pool's materialized entitlements say which tier delivers. A
// subscription S pays for Pro, a grant of Basic displaced it, and the row
// records the console's pause.
import (
"database/sql"
"errors"
"slices"
"strings"
"testing"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world"
)
const resumeReason = "member:resume"
// resumeFixture is an organization on a ladder of Basic, Plus, Pro and Max,
// before any subscription or grant.
type resumeFixture struct {
t *testing.T
w *world.World
q *dbtest.Q
org world.Org
orgType string
ladder world.Ladder
names map[string]string // product id -> name
basic world.Product
plus world.Product
pro world.Product
max world.Product
basicPx world.Price
account world.Account
sub world.Subscription
grant world.Grant
}
func newResumeFixture(t *testing.T) *resumeFixture {
t.Helper()
d := dbtest.Open(t)
registerPositionKeys(t, d)
w := world.New(t, d)
f := &resumeFixture{t: t, w: w, q: dbtest.On(t, d), names: map[string]string{}}
for i, name := range []string{"Basic", "Plus", "Pro", "Max"} {
p := w.Product(name)
px := w.Price(p, int32(1000*(i+1)))
w.Rule(p, sitesLimit(int64(i+1)))
f.names[p.ID] = name
switch name {
case "Basic":
f.basic, f.basicPx = p, px
case "Plus":
f.plus = p
case "Pro":
f.pro = p
case "Max":
f.max = p
}
}
f.ladder = w.Ladder("Resume", f.basic, f.plus, f.pro, f.max)
f.orgType = w.OrgType("resume")
f.org = w.Org("Resume", f.orgType, w.Person("Ann"))
f.account = w.BillingAccount(f.org, "Account")
return f
}
// under makes S pay for paid while a grant of granted holds the ladder, and
// records the console's pause on S.
func (f *resumeFixture) under(paid, granted world.Product) {
f.t.Helper()
f.sub = f.w.Subscribe(f.org, world.SubOpts{}, paid)
f.grant = f.w.Issue(f.org, granted, world.IssueOpts{})
f.w.PauseCollection(f.sub)
}
func (f *resumeFixture) input() entitlements.ResumeSubscriptionInput {
return entitlements.ResumeSubscriptionInput{
PoolID: f.org.PoolID, LadderID: f.ladder.ID, SubscriptionID: f.sub.ID, Reason: resumeReason,
}
}
func (f *resumeFixture) resume() error {
f.t.Helper()
return entitlements.ResumeSubscription(f.w.Ctx, f.w.DB, f.input())
}
// holder names what holds the ladder: the tier and the source.
func (f *resumeFixture) holder(ladderID string) string {
f.t.Helper()
var product string
var sub, grant sql.NullString
err := f.w.DB.QueryRowContext(f.w.Ctx, `
SELECT l.product_id::text, p.subscription_id::text, p.grant_id::text
FROM core.pool_provision_ladders l
JOIN core.pool_provisions p ON p.provision_id = l.provision_id
WHERE l.pool_id = $1 AND l.plan_ladder_id = $2 AND l.status IN ('active', 'suspended')`,
f.org.PoolID, ladderID).Scan(&product, &sub, &grant)
if errors.Is(err, sql.ErrNoRows) {
return "nothing"
}
if err != nil {
f.t.Fatalf("read the holder of the ladder: %v", err)
}
name := f.names[product]
if name == "" {
name = product
}
switch {
case sub.Valid && sub.String == f.sub.ID:
return name + " by the subscription"
case sub.Valid:
return name + " by another subscription"
case grant.Valid:
return name + " by a grant"
}
return name + " by a purchase"
}
// written counts what an act leaves in the pool: positions, transitions and
// outbox entries for the organization's subscriptions.
func (f *resumeFixture) written() [3]int64 {
f.t.Helper()
return [3]int64{
f.q.Int(`SELECT count(*) FROM core.pool_provision_ladders WHERE pool_id = $1`, f.org.PoolID),
f.q.Int(`SELECT count(*) FROM core.pool_provision_transitions WHERE pool_id = $1`, f.org.PoolID),
f.q.Int(`SELECT count(*) FROM core.outbox o WHERE o.action_type = 'reconcile_subscription'
AND o.payload->>'subscription_id' IN (SELECT s.subscription_id::text FROM core.subscriptions s
WHERE s.billing_account_id = $1)`, f.account.ID),
}
}
// entries are the subscription ids of the organization's reconcile entries, in
// the order the entries were written.
func (f *resumeFixture) entries() []string {
f.t.Helper()
return f.q.Strings(`SELECT o.payload->>'subscription_id' FROM core.outbox o
WHERE o.action_type = 'reconcile_subscription'
AND o.payload->>'subscription_id' IN (SELECT s.subscription_id::text FROM core.subscriptions s
WHERE s.billing_account_id = $1)
ORDER BY o.id`, f.account.ID)
}
// sites is the pool's materialized limit of the sites key.
func (f *resumeFixture) sites() int64 {
f.t.Helper()
return f.q.Int(`SELECT resource_limit FROM core.numeric_entitlements WHERE pool_id = $1 AND resource_key = $2`,
f.org.PoolID, positionsSitesKey)
}
func TestResumeSubscription_TakesTheLadderFromTheGrant(t *testing.T) {
f := newResumeFixture(t)
f.under(f.pro, f.basic)
other := f.w.BareSubscription(f.account, time.Now().Add(-24*time.Hour), time.Now().Add(24*time.Hour))
if f.sites() != 1 {
t.Fatalf("before the resume, the pool's entitlements = %d sites, want Basic's 1", f.sites())
}
if err := f.resume(); err != nil {
t.Fatalf("resume: %v", err)
}
if got := f.holder(f.ladder.ID); got != "Pro by the subscription" {
t.Errorf("after the resume, the ladder holds %v, want Pro by the subscription", got)
}
var kind, actor, reason string
f.q.Scan([]any{&kind, &actor, &reason}, `SELECT t.transition_type, t.actor_type, t.reason
FROM core.pool_provision_transitions t JOIN core.pool_provisions p ON p.provision_id = t.provision_id
WHERE p.subscription_id = $1 AND t.transition_type <> 'end' ORDER BY t.created_at DESC LIMIT 1`, f.sub.ID)
if kind != "upgrade" || actor != "system" || reason != resumeReason {
t.Errorf("the resume's transition = %s by %s for %q, want an upgrade by system for %q", kind, actor, reason, resumeReason)
}
if n := f.q.Int(`SELECT count(*) FROM core.grant_replacements WHERE grant_id = $1 AND replacing_subscription_id = $2`,
f.grant.ID, f.sub.ID); n != 1 {
t.Errorf("the grant is not recorded as replaced by the subscription")
}
want := []string{f.sub.ID, other.ID}
slices.Sort(want)
if got := f.entries(); !slices.Equal(got, want) {
t.Errorf("the resume wrote entries for %v, want %v", got, want)
}
if got := f.sites(); got != 3 {
t.Errorf("after the resume, the pool's entitlements = %d sites, want Pro's 3", got)
}
}
// resumeRefusalRow is one refusal: a fixture and the error the act answers.
type resumeRefusalRow struct {
name string
build func(f *resumeFixture)
want error
}
// checkResumeRefusal builds the row's fixture, resumes, and compares the
// answer and what the act left behind.
func checkResumeRefusal(t *testing.T, row resumeRefusalRow) {
t.Helper()
f := newResumeFixture(t)
row.build(f)
before := f.written()
err := f.resume()
if !errors.Is(err, row.want) || f.written() != before {
t.Errorf("%s: resume = %v, want %v, with nothing written", row.name, err, row.want)
}
}
func TestResumeSubscription_Refusals(t *testing.T) {
proUnderBasic := func(edit func(f *resumeFixture)) func(f *resumeFixture) {
return func(f *resumeFixture) {
f.under(f.pro, f.basic)
if edit != nil {
edit(f)
}
}
}
rows := []resumeRefusalRow{
{"no pause recorded", func(f *resumeFixture) {
f.sub = f.w.Subscribe(f.org, world.SubOpts{}, f.pro)
f.grant = f.w.Issue(f.org, f.basic, world.IssueOpts{})
}, entitlements.ErrResumeRefused},
{"a pause made in the Dashboard", func(f *resumeFixture) {
f.under(f.pro, f.basic)
f.w.ForeignPause(f.sub)
}, entitlements.ErrResumeRefused},
{"a marker with no pause", proUnderBasic(func(f *resumeFixture) {
if err := billing.New(f.w.DB).UpdateSubscriptionCollection(f.w.Ctx, billing.UpdateSubscriptionCollectionParams{
SubscriptionID: f.sub.ID, PauseCollectionMarked: true,
}); err != nil {
f.w.TB.Fatalf("record a marker with no pause: %v", err)
}
}), entitlements.ErrResumeRefused},
{"a grant of the paid tier", func(f *resumeFixture) { f.under(f.pro, f.pro) }, entitlements.ErrResumeRefused},
{"a grant above the paid tier", func(f *resumeFixture) { f.under(f.plus, f.pro) }, entitlements.ErrResumeRefused},
{"held by another subscription", proUnderBasic(func(f *resumeFixture) {
f.w.Subscribe(f.org, world.SubOpts{}, f.plus)
}), entitlements.ErrResumeRefused},
{"a cancellation scheduled", proUnderBasic(func(f *resumeFixture) { f.w.CancelAtPeriodEnd(f.sub) }),
entitlements.ErrResumeRefused},
{"a downgrade scheduled", proUnderBasic(func(f *resumeFixture) {
f.w.ScheduleDowngrade(f.sub, f.basicPx, time.Now().Add(24*time.Hour))
}), entitlements.ErrResumeRefused},
{"past due", proUnderBasic(func(f *resumeFixture) { f.w.Suspend(f.sub, "past_due") }), entitlements.ErrResumeSuspended},
{"paused", proUnderBasic(func(f *resumeFixture) { f.w.Suspend(f.sub, "paused") }), entitlements.ErrResumePaused},
{"canceled", proUnderBasic(func(f *resumeFixture) { f.w.EndSubscription(f.sub) }), entitlements.ErrResumeRefused},
{"a bundle", proUnderBasic(func(f *resumeFixture) { f.w.Ladder("Bundle", f.pro) }), entitlements.ErrResumeRefused},
{"the product drafted", proUnderBasic(func(f *resumeFixture) { f.w.Draft(f.pro) }), entitlements.ErrResumeRefused},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) { checkResumeRefusal(t, row) })
}
}
func TestResumeSubscription_OverABundleGrantEndsItOnItsOtherLadder(t *testing.T) {
f := newResumeFixture(t)
other := f.w.Ladder("Other", f.basic)
f.w.DefaultLadder(f.orgType, other)
f.under(f.pro, f.basic)
if got := f.holder(other.ID); got != "Basic by a grant" {
t.Fatalf("before the resume, the other ladder holds %v, want Basic by a grant", got)
}
if err := f.resume(); err != nil {
t.Fatalf("resume: %v", err)
}
if got := f.holder(other.ID); got != "nothing" {
t.Errorf("after the resume over a bundle grant, the other ladder holds %v, want nothing", got)
}
}
func TestResumeSubscription_ASecondSubmitConfersOnce(t *testing.T) {
f := newResumeFixture(t)
f.under(f.pro, f.basic)
if err := f.resume(); err != nil {
t.Fatalf("resume: %v", err)
}
err := f.resume()
n := f.q.Int(`SELECT count(*) FROM core.pool_provision_transitions t
JOIN core.pool_provisions p ON p.provision_id = t.provision_id
WHERE p.subscription_id = $1 AND t.transition_type = 'upgrade' AND t.plan_ladder_id = $2`, f.sub.ID, f.ladder.ID)
if !errors.Is(err, entitlements.ErrSubscriptionHoldsLadder) || n != 1 {
t.Errorf("a second resume = %v with %d transitions, want the holds refusal and 1", err, n)
}
}
// TestResumeSubscription_AnEndingActFirst: a revocation or an expiry of the
// grant holds the pool row while the resume waits; the ending act delivers the
// subscription (D7), and the resume then finds it holding the ladder. A second
// transaction holds the lock the reconcile takes, keyed by the subscription's
// Stripe id: a resume that took it would wait on it, and its lock timeout would
// fail it.
func TestResumeSubscription_AnEndingActFirst(t *testing.T) {
rows := []struct {
name string
end func(f *resumeFixture, tx *sql.Tx) error
}{
{"revocation", func(f *resumeFixture, tx *sql.Tx) error {
_, err := entitlements.RevokeGrantTx(f.w.Ctx, tx, entitlements.RevokeGrantInput{
GrantID: f.grant.ID, OrgID: f.org.ID, RevokedByPersonID: f.org.Owner.ID,
})
return err
}},
{"expiry", func(f *resumeFixture, tx *sql.Tx) error {
_, err := entitlements.ExpireGrantTx(f.w.Ctx, tx, f.grant.ID)
return err
}},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) { checkEndingActFirst(t, row.name, row.end) })
}
}
func checkEndingActFirst(t *testing.T, name string, end func(f *resumeFixture, tx *sql.Tx) error) {
t.Helper()
f := newResumeFixture(t)
f.under(f.pro, f.basic)
f.w.MapSubscription(f.sub)
ending, err := entitlements.BeginMaterializing(f.w.Ctx, f.w.DB)
if err != nil {
t.Fatalf("begin the %s: %v", name, err)
}
defer ending.Rollback()
if err := end(f, ending); err != nil {
t.Fatalf("%s: %v", name, err)
}
var endingPID int
if err := ending.QueryRowContext(f.w.Ctx, `SELECT pg_backend_pid()`).Scan(&endingPID); err != nil {
t.Fatalf("read the backend of the %s: %v", name, err)
}
holder, err := f.w.DB.BeginTx(f.w.Ctx, nil)
if err != nil {
t.Fatalf("begin the subscription's lock holder: %v", err)
}
defer holder.Rollback()
if _, err := holder.ExecContext(f.w.Ctx, `SELECT pg_advisory_xact_lock(hashtext($1)::bigint)`, "sub_"+f.sub.ID); err != nil {
t.Fatalf("take the subscription's advisory lock: %v", err)
}
if _, err := holder.ExecContext(f.w.Ctx, `UPDATE core.subscriptions SET status = status WHERE subscription_id = $1`, f.sub.ID); err != nil {
t.Fatalf("lock the subscription's row: %v", err)
}
done := make(chan error, 1)
go func() { done <- f.resume() }()
dbtest.AwaitLockWait(t, f.w.DB, dbtest.Backend{BlockedBy: endingPID})
if err := ending.Commit(); err != nil {
t.Fatalf("commit the %s: %v", name, err)
}
got := <-done
switch {
case errors.Is(got, entitlements.ErrSubscriptionHoldsLadder):
case got != nil && strings.Contains(got.Error(), "lock timeout"):
t.Errorf("the resume took the subscription's advisory lock: %v", got)
default:
t.Errorf("after a concurrent %s, resume = %v, want the holds refusal", name, got)
}
if holds := f.holder(f.ladder.ID); holds != "Pro by the subscription" {
t.Errorf("after a concurrent %s, the ladder holds %v, want Pro by the subscription", name, holds)
}
}
func TestResumeSubscription_AMarkedGrantResumesWhenTheSubscriptionEnds(t *testing.T) {
f := newResumeFixture(t)
f.sub = f.w.Subscribe(f.org, world.SubOpts{}, f.pro)
f.grant = f.w.Issue(f.org, f.basic, world.IssueOpts{Marked: true})
f.w.PauseCollection(f.sub)
if err := f.resume(); err != nil {
t.Fatalf("resume: %v", err)
}
f.w.EndSubscription(f.sub)
if got := f.holder(f.ladder.ID); got != "Basic by a grant" {
t.Errorf("after the subscription ended, the ladder holds %v, want the marked grant's Basic", got)
}
}
+11
View File
@@ -11,6 +11,7 @@ import (
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
)
@@ -44,3 +45,13 @@ func SetRequestBackstop(t testing.TB, f func(context.Context, integration.DBTX,
requestBackstop = f
t.Cleanup(func() { requestBackstop = prev })
}
// SetResumeSubscription replaces the act ResumePlan calls for the test and
// restores it in t.Cleanup. A test that sets it does not call t.Parallel: the
// variable is the package's.
func SetResumeSubscription(t testing.TB, f func(context.Context, *sql.DB, entitlements.ResumeSubscriptionInput) error) {
t.Helper()
prev := resumeSubscription
resumeSubscription = f
t.Cleanup(func() { resumeSubscription = prev })
}
+66
View File
@@ -562,6 +562,72 @@ func KeepCurrentPlan(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMo
return ReconcileSubscription(ctx, db, logger, keyMode, ls.stripeSubID, reason)
}
// ErrResumeUnavailable is Resume plan's refusal for every reason the console
// cannot name to the member beyond "that plan is no longer available": the
// act's entitlements.ErrResumeRefused. The decision refuses each of them before
// ResumePlan runs, so it answers only a race.
var ErrResumeUnavailable = errors.New("resume: the plan cannot be resumed")
// resumeSubscription is entitlements.ResumeSubscription, held in a variable so
// that TestResumePlan_MapsTheActsRefusals can make the act answer each of its
// refusals, which ResumePlan's own checks and the offer decision refuse first,
// so the database alone reaches the mappings only in a race.
var resumeSubscription = entitlements.ResumeSubscription
// ResumePlan takes the ladder back from a grant ranked below the tier the
// subscription pays for, for the subscription's own tier at its own price
// (grant-pauses-subscription-billing D20). It resolves the paying subscription
// as Keep plan does (resolveSubscriptionOnLadder) and answers ErrSameTier when
// that subscription already holds the ladder, ErrSubscriptionPaused or
// ErrSubscriptionSuspended from its status, and ErrPriceUnreachable when its
// Stripe mapping is out of reach under keyMode, all before any write: the
// outbox skips such a subscription, so the resumed plan would deliver unpaid
// with no clear ever made. It then calls entitlements.ResumeSubscription and
// maps its refusals. It makes no Stripe call and calls no ReconcileSubscription:
// the outbox entry the act wrote runs the reconcile that clears the pause.
func ResumePlan(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, orgID, ladderID, reason string) error {
if logger == nil {
logger = slog.Default()
}
ls, err := resolveSubscriptionOnLadder(ctx, db, orgID, ladderID)
if err != nil {
return err
}
switch {
case ls.holdsLadder:
return ErrSameTier
case ls.subscriptionStatus == "paused":
return ErrSubscriptionPaused
case ls.positionStatus == "suspended":
return ErrSubscriptionSuspended
case !internalstripe.MappingReachable(ls.subLivemode, ls.subSyncStatus, keyMode):
logUnreachableMapping(logger, "plan_change: subscription mapping unreachable under the current key; skipping",
ls.coreSubID, ls.stripeSubID, ls.subLivemode, ls.subSyncStatus, keyMode)
return ErrPriceUnreachable
}
pool, err := entitlements.New(db).GetDefaultPoolByOrgID(ctx, orgID)
if err != nil {
return fmt.Errorf("resume: pool of org %s: %w", orgID, err)
}
err = resumeSubscription(ctx, db, entitlements.ResumeSubscriptionInput{
PoolID: pool.PoolID,
LadderID: ladderID,
SubscriptionID: ls.coreSubID,
Reason: reason,
})
switch {
case errors.Is(err, entitlements.ErrSubscriptionHoldsLadder):
return ErrSameTier
case errors.Is(err, entitlements.ErrResumeSuspended):
return ErrSubscriptionSuspended
case errors.Is(err, entitlements.ErrResumePaused):
return ErrSubscriptionPaused
case errors.Is(err, entitlements.ErrResumeRefused):
return ErrResumeUnavailable
}
return err
}
// SwitchPreview is what a paid→paid switch costs and when
// (plan-switch-timing D6). For an upgrade, NetAmount is the amount due now
// in the currency's smallest unit, prorated at ProrationDate, which the
@@ -12,9 +12,11 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"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/fulfillment"
"git.coopcloud.tech/wiki-cafe/member-console/internal/stripetest"
@@ -129,3 +131,129 @@ func TestAPastDuePayingSubscriptionIsNotCancelledImmediately(t *testing.T) {
t.Fatalf("an immediate cancellation of a past-due paying subscription = %v (calls %v), want ErrImmediateCancelSuspended before any Stripe call", err, mock.Calls[mark:])
}
}
// Resume plan (design D20): the paying subscription takes the ladder back from
// a grant ranked below its tier, and the outbox entry the act wrote clears the
// pause. resumableWorld is payingUnderGrantOf with the ladder reordered so that
// the granted tier (Pro) ranks below the subscription's (Plus).
func resumableWorld(t *testing.T) (*sql.DB, fixture, *stripetest.MockBackend) {
t.Helper()
database, fx, mock, pro, _ := payingUnderGrantOf(t, false)
ctx := context.Background()
tx, err := entitlements.BeginMaterializing(ctx, database)
if err != nil {
t.Fatalf("begin the reorder: %v", err)
}
if err := entitlements.RenumberTiers(ctx, billing.New(tx), fx.ladderID, []string{pro.productID, fx.productID}); err != nil {
_ = tx.Rollback()
t.Fatalf("reorder the ladder: %v", err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit the reorder: %v", err)
}
return database, fx, mock
}
func resumePlan(database *sql.DB, fx fixture) error {
return fulfillment.ResumePlan(context.Background(), database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, "member:resume")
}
// ResumePlan sends nothing to Stripe; the clear is the outbox entry's, and it
// is the clear alone.
func TestResumePlan_MakesNoStripeCall(t *testing.T) {
database, fx, mock := resumableWorld(t)
stripeAppliesCollection(mock, fx.stripeSubID)
mark := len(mock.Calls)
if err := resumePlan(database, fx); err != nil {
t.Fatalf("resume: %v", err)
}
if sent := mock.Calls[mark:]; len(sent) != 0 {
t.Fatalf("resume sent %v to Stripe, want nothing", sent)
}
if err := applyCollection(t, database, discardLogger(), fx); err != nil {
t.Fatalf("ApplySubscriptionCollection: %v", err)
}
calls := updates(mock, fx.stripeSubID, mark)
clearAlone := len(calls) == 1 && calls[0].Form.Has("pause_collection") && calls[0].Form.Get("pause_collection") == "" &&
calls[0].Form.Has(markerField) && calls[0].Form.Get(markerField) == ""
for _, c := range calls {
for k := range c.Form {
if strings.HasPrefix(k, "items") {
clearAlone = false
}
}
}
if !clearAlone {
t.Errorf("the clear after the resume = %v, want the clear alone", calls)
}
}
func TestResumePlan_RefusesAnUnreachableMapping(t *testing.T) {
database, fx, _ := resumableWorld(t)
core := coreSubID(t, database, fx.stripeSubID)
if _, err := database.Exec(`UPDATE stripe.subscription_mappings SET livemode = TRUE WHERE subscription_id = $1`, core); err != nil {
t.Fatalf("map the subscription for the other key mode: %v", err)
}
transitions := scalarInt(t, database, `SELECT count(*) FROM core.pool_provision_transitions WHERE pool_id = $1`, fx.poolID)
err := resumePlan(database, fx)
after := scalarInt(t, database, `SELECT count(*) FROM core.pool_provision_transitions WHERE pool_id = $1`, fx.poolID)
if !errors.Is(err, fulfillment.ErrPriceUnreachable) || after != transitions || reconcileEntries(t, database, core) != 0 {
t.Errorf("resume over an unreachable mapping = %v, want ErrPriceUnreachable", err)
}
}
// ResumePlan's own checks and the offer decision refuse the holds, suspended
// and paused cases first, so the database alone reaches these mappings only in
// a race; the test makes the act answer each refusal.
func TestResumePlan_MapsTheActsRefusals(t *testing.T) {
rows := []struct {
name string
act error
want error
}{
{"the subscription holds the ladder", entitlements.ErrSubscriptionHoldsLadder, fulfillment.ErrSameTier},
{"the payment is due", entitlements.ErrResumeSuspended, fulfillment.ErrSubscriptionSuspended},
{"the subscription is paused", entitlements.ErrResumePaused, fulfillment.ErrSubscriptionPaused},
{"the resume is refused", entitlements.ErrResumeRefused, fulfillment.ErrResumeUnavailable},
{"the resume is refused, wrapped", fmt.Errorf("%w: a draft", entitlements.ErrResumeRefused), fulfillment.ErrResumeUnavailable},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
database, fx, _ := resumableWorld(t)
fulfillment.SetResumeSubscription(t, func(context.Context, *sql.DB, entitlements.ResumeSubscriptionInput) error { return row.act })
if err := resumePlan(database, fx); !errors.Is(err, row.want) {
t.Errorf("%s: ResumePlan = %v, want %v", row.name, err, row.want)
}
})
}
}
// A clear Stripe refuses leaves the ladder with the subscription, and the drift
// sweep, once the entry is gone, asks for it again: the rule wants no pause and
// the row still records the console's marker.
func TestResumePlan_AFailedClearKeepsTheLadder(t *testing.T) {
database, fx, mock := resumableWorld(t)
core := coreSubID(t, database, fx.stripeSubID)
if err := resumePlan(database, fx); err != nil {
t.Fatalf("resume: %v", err)
}
mock.Handle = func(c stripetest.Call) (any, error, bool) {
if c.Method == "POST" && c.Path == "/v1/subscriptions/"+fx.stripeSubID {
return nil, &stripe.Error{HTTPStatusCode: 500, Msg: "internal error"}, true
}
return nil, nil, false
}
if err := applyCollection(t, database, discardLogger(), fx); err == nil {
t.Fatalf("the clear Stripe refused returned no error")
}
if _, err := database.Exec(`DELETE FROM core.outbox WHERE action_type = 'reconcile_subscription' AND payload->>'subscription_id' = $1`, core); err != nil {
t.Fatalf("clear the entries: %v", err)
}
if _, err := fulfillment.SweepCollectionDrift(context.Background(), database, discardLogger(), stripetest.KeyMode()); err != nil {
t.Fatalf("SweepCollectionDrift: %v", err)
}
holder, asked := ladderHolder(t, database, fx), reconcileEntries(t, database, core)
if holder != fx.productID || asked != 1 {
t.Errorf("after a failed clear, the ladder holds %v and drift is %v", holder, asked == 1)
}
}
+33 -2
View File
@@ -120,6 +120,10 @@ const (
// Cancel is the downgrade at the period end: it cancels the subscription,
// after which the held rung or the free rung follows.
Cancel Kind = "cancel"
// Resume takes the ladder back from a grant ranked below the tier the
// paying subscription pays for, for that subscription's own tier at its own
// price: the subscription changes no item and charges nothing.
Resume Kind = "resume"
)
// Reason says why a move is not offered or is disabled.
@@ -176,6 +180,10 @@ type Move struct {
// grant (rule 3a.2): the subscription takes the ladder and the grant is
// replaced.
ReplacesGrant bool
// OwnTier is true when that switch's target is the current rung the grant
// holds: the subscription starts paying for the plan the card already
// shows as current.
OwnTier bool
// NewPeriod is true when the switch moves the paying subscription to a
// price of another recurring interval, so Stripe charges the new price's
// full period less the unused time and moves the billing date.
@@ -217,7 +225,20 @@ func (p Position) rankOf(productID string) (int32, bool) {
// 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.1 The target is the paying product: Resume, enabled, relation
// Upgrade, when a grant holds the ladder's rung and that rung ranks below
// the paying product (a grant at or above it, or the default, leaves the
// paid tier nothing to take back), the subscription is not suspended (a
// past-due one pays its invoice first and a paused one is changed by
// nobody), its row records the console's pause (a pause not yet confirmed,
// a failed one or one made in the Dashboard is never cleared by the
// console, so the resumed plan would deliver unpaid), nothing is scheduled
// on it (a cancellation would end the resumed subscription, a downgrade
// would make the confirmation's price false), and its product is a tier of
// this ladder alone (a conferral places the whole product, which #271
// decides); otherwise none, PaidHere. No price rule and no member gate
// apply: Resume keeps the subscription's own item and price, as Keep plan
// applies none.
// - 3a.2 A grant holds, the paying subscription is not suspended, its
// product and the target are tiers of no other ladder, and the target
// ranks at or above the current rung and above the paying product: Switch
@@ -317,7 +338,7 @@ func (a ask) payingRules() (Move, bool) {
}
switch {
case a.ProductID == p.ProductID:
return Move{Reason: PaidHere}, true
return a.resume(), true
case p.GrantHolds && !p.Suspended && !p.OtherLadders && !a.OtherLadders && a.rank >= a.current && a.rank > p.Rank:
return a.paidSwitch(), true
case a.ProductID == a.Current:
@@ -326,6 +347,15 @@ func (a ask) payingRules() (Move, bool) {
return Move{Reason: PaysOnLadder}, true
}
// resume is rule 3a.1's answer.
func (a ask) resume() Move {
p := a.Paying
if p.GrantHolds && a.current < p.Rank && !p.Suspended && p.ConsolePaused && !p.MoveScheduled && !p.OtherLadders {
return Move{Kind: Resume, Enabled: true, Relation: Upgrade}
}
return Move{Reason: PaidHere}
}
// paidSwitch is rule 3a.2's answer. Offer sets the current product's relation
// to Downgrade (the granted tier is the current one), so the rule sets Upgrade
// itself.
@@ -333,6 +363,7 @@ func (a ask) paidSwitch() Move {
m := priceRule(Switch, a.Target)
m.Relation = Upgrade
m.ReplacesGrant = m.Kind == Switch
m.OwnTier = m.ReplacesGrant && a.ProductID == a.Current
if !m.Enabled {
return m
}
+44 -4
View File
@@ -204,8 +204,28 @@ func TestOffer(t *testing.T) {
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: "the paid tier above a lower grant", pos: consolePaused(payingOn(holds("basic", false), "plus", true)),
target: target("plus"), kind: Resume, enabled: true, relation: Upgrade},
{name: "the paid tier above a lower grant, past due",
pos: payingSuspended(consolePaused(payingOn(holds("basic", false), "plus", true)), false),
target: target("plus"), reason: PaidHere},
{name: "the paid tier above a lower grant, no console pause", pos: payingOn(holds("basic", false), "plus", true),
target: target("plus"), reason: PaidHere},
{name: "the paid tier above a lower grant, a move scheduled",
pos: payingMoveScheduled(consolePaused(payingOn(holds("basic", false), "plus", true))),
target: target("plus"), reason: PaidHere},
{name: "the paid tier above a lower grant, retired", pos: consolePaused(payingOn(holds("basic", false), "plus", true)),
target: with(target("plus"), func(t *Target) { t.Listed = false }), kind: Resume, enabled: true, relation: Upgrade},
{name: "the paid tier above a lower grant, the default holding the rung",
pos: consolePaused(payingOn(holds("basic", false), "plus", false)),
target: target("plus"), reason: PaidHere},
{name: "the paid bundle above a lower grant",
pos: payingBundle(consolePaused(payingOn(holds("basic", false), "plus", true))),
target: target("plus"), reason: PaidHere},
{name: "the paid tier under a grant of its own tier", pos: consolePaused(payingOn(holds("plus", false), "plus", true)),
target: target("plus"), reason: PaidHere},
{name: "the paid tier below the grant", pos: consolePaused(payingOn(holds("pro", 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),
@@ -213,7 +233,7 @@ func TestOffer(t *testing.T) {
{name: "a tier above a grant that displaced a subscription", pos: payingOn(holds("plus", false), "basic", true),
target: target("pro"), kind: Switch, enabled: true, relation: Upgrade, replacesGrant: true},
{name: "the granted tier over a paying subscription", pos: payingOn(holds("plus", false), "basic", true),
target: target("plus"), kind: Switch, enabled: true, relation: Upgrade, replacesGrant: true},
target: target("plus"), kind: Switch, enabled: true, relation: Upgrade, replacesGrant: true, ownTier: true},
{name: "a tier above the paid tier over a lower grant", pos: payingOn(holds("basic", false), "standard", true),
target: target("pro"), kind: Switch, enabled: true, relation: Upgrade, replacesGrant: true},
{name: "the granted tier below the paid tier", pos: payingOn(holds("basic", false), "plus", true),
@@ -293,6 +313,24 @@ func payingBundle(p Position) Position {
return p
}
// consolePaused makes the paying subscription's row record the console's
// pause.
func consolePaused(p Position) Position {
paying := *p.Paying
paying.ConsolePaused = true
p.Paying = &paying
return p
}
// payingMoveScheduled schedules a cancellation or a downgrade on the paying
// subscription.
func payingMoveScheduled(p Position) Position {
paying := *p.Paying
paying.MoveScheduled = true
p.Paying = &paying
return p
}
// payingSuspended makes the paying subscription suspended, paused or not.
func payingSuspended(p Position, paused bool) Position {
paying := *p.Paying
@@ -336,6 +374,8 @@ type offerCase struct {
// confirmation (rule 3a.2).
replacesGrant bool
newPeriod bool
// ownTier is true when the switch's target is the current rung.
ownTier bool
}
// checkOffer compares the decision with the row's move, printing the row's
@@ -344,7 +384,7 @@ func checkOffer(t *testing.T, tc offerCase) {
t.Helper()
got := Offer(tc.pos, tc.target)
want := Move{Kind: tc.kind, Enabled: tc.enabled, Reason: tc.reason, Relation: tc.relation, Held: tc.held,
ReplacesGrant: tc.replacesGrant, NewPeriod: tc.newPeriod}
ReplacesGrant: tc.replacesGrant, OwnTier: tc.ownTier, NewPeriod: tc.newPeriod}
if got != want {
t.Errorf("Offer(%s) = %+v, want %+v", tc.name, got, want)
}
+1
View File
@@ -93,6 +93,7 @@ func init() {
{Label: "Cancel plan", Method: "POST", Path: "/partials/member/plans/cancel", Modal: true},
{Label: "Keep plan", Method: "POST", Path: "/partials/member/plans/keep",
Note: "the current plan's control, and the paid plan's card under a grant, while a downgrade or a cancellation is scheduled"},
{Label: "Resume plan", Method: "POST", Path: "/partials/member/plans/resume", Modal: true},
{Label: "Checkout", Method: "POST", Path: "/billing/checkout",
Note: "fired from a plan's move control and from an add-on's Add"},
@@ -134,6 +134,10 @@ func TestPlansOfferPaidMoveGate(t *testing.T) {
paused := []LadderViewModel{{Tiers: []TierViewModel{{Relation: "current", LadderID: "l1", Suspension: suspensionPaused}}}}
unprojected := []LadderViewModel{{Tiers: []TierViewModel{{Relation: "current", Suspension: suspensionPaymentDue}}}}
paidDue := []LadderViewModel{{Tiers: []TierViewModel{{Relation: "upgrade", PaidUnderGrant: true, LadderID: "l1", Suspension: suspensionPaymentDue}}}}
resumes := []LadderViewModel{{Tiers: []TierViewModel{{Relation: "upgrade", PaidUnderGrant: true, LadderID: "l1", CanResume: true}}}}
if !plansOfferPaidMove(resumes) {
t.Error("an enabled Resume does not earn the line")
}
if !plansOfferPaidMove(paymentDue) {
t.Error("a plan whose payment is due does not earn the line")
}
+49 -5
View File
@@ -26,7 +26,9 @@ import (
// prorated at (D6); a downgrade states the date the current tier ends.
// then is the target's recurring price ("$30.00 a month"). replacesGrant opens
// the second paragraph with "Your subscription replaces the grant." for a
// switch of a subscription that pays under a grant. newPeriodCharge, when set,
// switch of a subscription that pays under a grant. ownTier, true when that
// switch's target is the current rung the grant holds, words the heading
// "Pay for <tier>?" in place of "Switch to <tier>?". newPeriodCharge, when set,
// is the target price's amount and replaces the charge sentence whatever the
// preview answered: Stripe charges the new price's full period less a credit
// for the unused time and moves the billing date, and previews nothing while
@@ -34,9 +36,13 @@ import (
// "for the rest of this period", which is false when the period restarts.
// Tier names are the one piece here that is not the console's own copy, so
// they alone are escaped.
func planMoveMessage(tierName, currentName, then string, replacesGrant bool, newPeriodCharge string, p fulfillment.SwitchPreview) template.HTML {
func planMoveMessage(tierName, currentName, then string, replacesGrant, ownTier bool, newPeriodCharge string, p fulfillment.SwitchPreview) template.HTML {
esc := template.HTMLEscapeString
msg := "<p class=\"mb-2\"><strong>Switch to " + esc(tierName) + "?</strong></p><p class=\"mb-0\">"
heading := "Switch to "
if ownTier {
heading = "Pay for "
}
msg := "<p class=\"mb-2\"><strong>" + heading + esc(tierName) + "?</strong></p><p class=\"mb-0\">"
if replacesGrant {
msg += "Your subscription replaces the grant. "
}
@@ -140,7 +146,7 @@ func (h *MemberProductsHandler) renderSwitchPreview(w http.ResponseWriter, ctx c
binding := forms.Binding{
Mode: forms.ModeRecord,
Values: values,
Message: planMoveMessage(tierName, h.currentTierName(ctx, orgID, ladderID), recurringPhrase(target), move.ReplacesGrant, newPeriodCharge, preview),
Message: planMoveMessage(tierName, h.currentTierName(ctx, orgID, ladderID), recurringPhrase(target), move.ReplacesGrant, move.OwnTier, newPeriodCharge, preview),
}
if formErr != "" {
errs := forms.NewErrors()
@@ -284,6 +290,40 @@ func (h *MemberProductsHandler) PostKeep(w http.ResponseWriter, r *http.Request)
h.renderPlansAfterMove(w, r.Context(), session.OrgID, err, "keep")
}
// PostResume handles POST /partials/member/plans/resume: the paid tier takes
// the ladder back from a grant ranked below it, at its own price, with nothing
// charged now (grant-pauses-subscription-billing D20). It names only the
// ladder. It asks the decision first (resumeOfferFor), so the route accepts only
// what the page draws, and makes no Stripe call: the outbox entry the act wrote
// clears the pause after the commit.
func (h *MemberProductsHandler) PostResume(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if h.Database == nil {
http.Error(w, "billing not configured", http.StatusServiceUnavailable)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
postedLadder := r.PostForm.Get("ladder_id")
if postedLadder == "" {
http.Error(w, "ladder_id is required", http.StatusBadRequest)
return
}
ladderID, err := h.resumeOfferFor(r.Context(), session.OrgID, postedLadder)
if err != nil {
h.renderPlansAfterMove(w, r.Context(), session.OrgID, err, "resume")
return
}
err = fulfillment.ResumePlan(r.Context(), h.Database, h.Logger, h.StripeMode, session.OrgID, ladderID, "member:resume")
h.renderPlansAfterMove(w, r.Context(), session.OrgID, err, "resume")
}
// errPlanNotOpenToMembers is a switch whose target product fails the member
// gate. It renders as the unavailable-plan message, the same one a retired
// price gets, since to the member the two are one fact.
@@ -365,7 +405,8 @@ func (h *MemberProductsHandler) renderPlansAfterMove(w http.ResponseWriter, ctx
case errors.Is(moveErr, fulfillment.ErrSameTier):
data.Error = "You're already on that plan."
case errors.Is(moveErr, fulfillment.ErrPriceInactive), errors.Is(moveErr, errPlanNotOpenToMembers),
errors.Is(moveErr, errPlanNotOffered), errors.Is(moveErr, fulfillment.ErrPriceUnreachable):
errors.Is(moveErr, errPlanNotOffered), errors.Is(moveErr, fulfillment.ErrPriceUnreachable),
errors.Is(moveErr, fulfillment.ErrResumeUnavailable):
data.Error = "That plan is no longer available. Refresh the page and choose a current plan."
default:
data.Error = "We couldn't complete that change. Try again in a moment."
@@ -382,6 +423,9 @@ func (h *MemberProductsHandler) renderPlansAfterMove(w http.ResponseWriter, ctx
case "keep":
// No banner: the plan's card itself changes back, from the
// scheduled move's line to the renewal line.
case "resume":
// No banner: the paid card itself becomes the current card, with
// its renewal line.
case "cancel":
// Only the period-end and commitment-boundary-deferred timings are
// reachable from the UI today (the cancel control never sends
+64 -16
View File
@@ -87,6 +87,7 @@ func (h *MemberProductsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /partials/member/plans/switch", h.PostSwitch)
mux.HandleFunc("POST /partials/member/plans/cancel", h.PostCancel)
mux.HandleFunc("POST /partials/member/plans/keep", h.PostKeep)
mux.HandleFunc("POST /partials/member/plans/resume", h.PostResume)
}
// TierViewModel represents one plan tier (a rung on a ladder) for rendering.
@@ -209,6 +210,22 @@ type TierViewModel struct {
PaidUnderGrant bool
GrantedName string
CollectionPaused bool
// CanResume offers "Resume plan" on the paid plan's card when the
// grant that holds the ladder ranks below it (grant-pauses-subscription-billing
// D20), through the confirmation ResumeTitle and ResumeBody state: the
// subscription takes the ladder back at its own price and nothing is
// charged now.
CanResume bool
ResumeTitle string
ResumeBody string
// Granted marks the current card a grant holds, not the organization
// type's default (default_sourced); GrantedUntil is the grant's
// valid_until in cardDateLayout, "" for a grant with no end date. A card
// with no other line draws them as its price line; the paid plan's card
// under a grant of its own tier draws GrantedUntil inside its paused line
// (design D10, D21).
Granted bool
GrantedUntil string
}
// PaysInvoice reports whether the card offers Pay invoice: a projected plan
@@ -484,10 +501,14 @@ func (h *MemberProductsHandler) buildTier(ctx context.Context, b *plansBuild, la
}
move := plans.Offer(pos, target)
b.moves[t.ProductID] = append(b.moves[t.ProductID], move)
tier.setMove(move, ladderID, price)
if isPaid {
tier.setPaidUnderGrant(ladderID, paid)
// The paid card's only moves are Resume and none (rule 3a.1); Resume
// is drawn by setPaidUnderGrant, never as a move control.
tier.setMove(plans.Move{}, ladderID, price)
tier.setPaidUnderGrant(ladderID, paid, move)
return tier, true, nil
}
tier.setMove(move, ladderID, price)
return tier, true, nil
}
@@ -505,10 +526,11 @@ func tierName(tiers []billing.ListTiersByLadderWithProductsRow, productID string
// line and the next step on its own card.
func (h *MemberProductsHandler) setCurrent(ctx context.Context, tier *TierViewModel, ladderID string, pos plans.Position, facts ladderFacts) {
tier.Relation = "current"
tier.setGranted(facts.Position)
// A grant of the tier a subscription pays for: the grant holds the rung,
// and the card is also the paid plan's.
if paid, ok := facts.paidCard(pos, tier.ProductID); ok {
tier.setPaidUnderGrant(ladderID, paid)
tier.setPaidUnderGrant(ladderID, paid, plans.Move{})
return
}
tier.Suspension = facts.Suspension
@@ -538,6 +560,23 @@ func (h *MemberProductsHandler) setCurrent(ctx context.Context, tier *TierViewMo
}
}
// setGranted marks the current card a grant holds and states the grant's end
// date, from the position row alone: it reads no other row and no clock. A date
// already passed is still drawn: the grant's expiry ends the position, and until
// the expiry runs (its workflow late or never scheduled) the grant still
// delivers, so the date stays true of its term, "Granted" alone would say the
// grant has no end, and no line would bring back the card with nothing to say
// (design D21, F81).
func (t *TierViewModel) setGranted(pos entitlements.ListLadderPositionsByPoolRow) {
if !pos.GrantID.Valid || pos.DefaultSourced {
return
}
t.Granted = true
if pos.GrantValidUntil.Valid {
t.GrantedUntil = pos.GrantValidUntil.Time.Format(cardDateLayout)
}
}
// moveDisabledReason is what a move the decision offers disabled reads.
const moveDisabledReason = "Not available for purchase yet"
@@ -564,9 +603,13 @@ func (t *TierViewModel) setMove(m plans.Move, ladderID string, p priceFacts) {
}
// moveLabel is the control's caption: the held move reads "Downgrade", like
// any move down, since it is a cancellation at the period end.
// any move down, since it is a cancellation at the period end. The switch on
// the granted tier's own current card reads "Pay for this plan", since beside
// "Current plan" "Upgrade" would read as moving to a plan the member is on.
func moveLabel(m plans.Move) string {
switch {
case m.OwnTier:
return "Pay for this plan"
case m.Held:
return "Downgrade"
case m.Relation == plans.Available:
@@ -654,10 +697,11 @@ func currentTier(lvm *LadderViewModel) TierViewModel {
// plansOfferPaidMove reports whether any tier's control is enabled and leads
// to a page that charges a card: a checkout, a switch, or the Pay invoice of a
// current tier or paid card whose payment is due (design D8; it opens Stripe's hosted
// invoice page, as Approve payment does). All of them charge the card on file
// or take a new one, now or at renewal. A cancel costs nothing, and a
// disabled control leads nowhere, so neither earns the Plans section a
// billing line.
// invoice page, as Approve payment does), or a paid card's Resume plan, which
// restarts charges on the card on file at the renewal (design D20). All of
// them charge the card on file or take a new one, now or at renewal. A cancel
// costs nothing, and a disabled control leads nowhere, so neither earns the
// Plans section a billing line.
func plansOfferPaidMove(ladders []LadderViewModel) bool {
for _, l := range ladders {
for _, t := range l.Tiers {
@@ -667,6 +711,9 @@ func plansOfferPaidMove(ladders []LadderViewModel) bool {
if (t.Relation == "current" || t.PaidUnderGrant) && t.PaysInvoice() {
return true
}
if t.CanResume {
return true
}
}
}
return false
@@ -821,15 +868,16 @@ func clauseWords(keeps, moves, leaves []*predictedClause) []string {
//
// Empty/false results mean the current tier carries no subscription at all —
// the free/default rung, or a paid tier an operator issued as a grant rather
// than through Stripe — and nothing renders; that is not billing information
// silently omitted, there genuinely is none here. A subscription that pays
// for a tier a grant displaced is not behind the position: its own card
// states it (setPaidUnderGrant). pending=true means the tier IS
// subscription-backed (the provision names a subscription_id) but its price
// or period could not be resolved yet: checkout completed and the ladder
// than through Stripe — and this renders no billing line; that is not billing
// information silently omitted, there genuinely is none here. The card of a
// tier a grant holds reads the grant's line instead (setGranted). A
// subscription that pays for a tier a grant displaced is not behind the
// position: its own card states it (setPaidUnderGrant). pending=true means the
// tier IS subscription-backed (the provision names a subscription_id) but its
// price or period could not be resolved yet: checkout completed and the ladder
// attachment landed, but the subscription's projection has not (still
// processing, or a data point on it did not resolve). The template must say
// so rather than rendering as if no price or renewal exists.
// processing, or a data point on it did not resolve). The template must say so
// rather than rendering as if no price or renewal exists.
func (h *MemberProductsHandler) resolveCurrentPlanBilling(ctx context.Context, pos entitlements.ListLadderPositionsByPoolRow) (priceText, renewsOn string, pending bool) {
if !pos.SubscriptionID.Valid {
return "", "", false
+25 -2
View File
@@ -4,7 +4,7 @@
package server
// The Products page's plans region, characterized state by state: for each of
// twenty-nine organizations, and of one more in a world of its own, the view
// thirty-three organizations, and of one more in a world of its own, 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
@@ -95,7 +95,7 @@ func registerGoldenKeys(t testing.TB, w *world.World) {
}
}
// plansGoldenFixture is the three ladders and twenty-nine organizations the
// plansGoldenFixture is the three ladders and thirty-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 {
@@ -307,6 +307,29 @@ func newPlansGoldenFixture(ctx context.Context, t *testing.T, w *world.World) *p
w.Issue(o, plus, world.IssueOpts{})
w.PauseCollection(sub)
// The grant ends on a date, which the granted current card states
// (design D21): over no subscription, over the subscription's own tier, and
// over a tier below the grant.
grantEnd := noon(2099, time.February, 20)
w.Issue(org("granted-plus-until"), plus, world.IssueOpts{ValidUntil: grantEnd})
o = org("granted-plus-own-tier-until")
sub = w.Subscribe(o, world.SubOpts{PeriodEnd: periodEnd}, plus)
w.Issue(o, plus, world.IssueOpts{ValidUntil: grantEnd})
w.PauseCollection(sub)
o = org("paid-plus-under-granted-pro-until")
sub = w.Subscribe(o, world.SubOpts{PeriodEnd: periodEnd}, plus)
w.Issue(o, pro, world.IssueOpts{ValidUntil: grantEnd})
w.PauseCollection(sub)
// A grant ranked below the tier the subscription pays for: the paid card
// offers Resume plan (design D20), the tiers above it the switch.
o = org("paid-standard-under-granted-basic")
sub = w.Subscribe(o, world.SubOpts{PeriodEnd: periodEnd}, standard)
w.Issue(o, basic, world.IssueOpts{})
w.PauseCollection(sub)
w.Issue(org("retired-current-rung"), gone, world.IssueOpts{})
w.Retire(gone)
return f
@@ -0,0 +1,84 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"database/sql"
"slices"
"testing"
"time"
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/plans"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
)
// TestGrantedCurrentCard: the current card a grant holds states the grant's
// term on its price line (design D21, Copy M13), read from the position row
// alone: a position row in, through setGranted on a current tier and
// member_plans.html, the line's whole text out.
func TestGrantedCurrentCard(t *testing.T) {
grant := uuid.NullUUID{UUID: uuid.New(), Valid: true}
until := func(y int, m time.Month, d int) sql.NullTime {
return sql.NullTime{Time: time.Date(y, m, d, 12, 0, 0, 0, time.UTC), Valid: true}
}
rows := []struct {
name string
pos entitlements.ListLadderPositionsByPoolRow
want []string
}{
{"an end date", entitlements.ListLadderPositionsByPoolRow{GrantID: grant, GrantValidUntil: until(2099, time.February, 20)},
[]string{"Granted until Feb 20, 2099"}},
{"no end date", entitlements.ListLadderPositionsByPoolRow{GrantID: grant}, []string{"Granted"}},
{"an end date passed", entitlements.ListLadderPositionsByPoolRow{GrantID: grant, GrantValidUntil: until(2020, time.January, 15)},
[]string{"Granted until Jan 15, 2020"}},
{"the default", entitlements.ListLadderPositionsByPoolRow{GrantID: grant, DefaultSourced: true}, nil},
{"a subscription", entitlements.ListLadderPositionsByPoolRow{SubscriptionID: grant}, nil},
}
set := MemberSet(t)
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
tier := TierViewModel{ProductID: "plus", Name: "Plus", Relation: "current"}
tier.setGranted(row.pos)
lvm := LadderViewModel{Name: "Wiki", Enrolled: true, Tiers: []TierViewModel{tier}}
doc := htmltest.Parse(t, Render(t, set, "member_plans.html", PlansData{Ladders: []LadderViewModel{lvm}}))
got := doc.Texts("p.text-muted.small.mb-2")
if !slices.Equal(got, row.want) {
t.Errorf("%s: price line %q, want %q", row.name, got, row.want)
}
})
}
}
// TestSwitchLabelUnderAGrant: the switch of a paused subscription reads "Pay
// for this plan" on the granted tier's own current card and "Upgrade" on a
// tier above it (design D9, Copy M6): a move in, through setMove and
// member_plans.html, the control's whole text out.
func TestSwitchLabelUnderAGrant(t *testing.T) {
rows := []struct {
name string
move plans.Move
want []string
}{
{"the granted tier's own card", plans.Move{Kind: plans.Switch, Enabled: true, Relation: plans.Upgrade, ReplacesGrant: true, OwnTier: true},
[]string{"Pay for this plan"}},
{"a tier above the granted one", plans.Move{Kind: plans.Switch, Enabled: true, Relation: plans.Upgrade, ReplacesGrant: true},
[]string{"Upgrade"}},
}
set := MemberSet(t)
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
tier := TierViewModel{ProductID: "plus", Name: "Plus"}
tier.setMove(row.move, "ladder-1", priceFacts{})
lvm := LadderViewModel{Name: "Wiki", Enrolled: true, Tiers: []TierViewModel{tier}}
doc := htmltest.Parse(t, Render(t, set, "member_plans.html", PlansData{Ladders: []LadderViewModel{lvm}}))
got := doc.Texts(".card-body button.btn")
if !slices.Equal(got, row.want) {
t.Errorf("%s: controls %q, want %q", row.name, got, row.want)
}
})
}
}
@@ -7,6 +7,7 @@ import (
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/plans"
)
// payingCard is what the paid plan's card needs beside the offer
@@ -51,7 +52,12 @@ func (p payingCard) cancelDate() string {
// the fact line, and draws no control. A subscription whose payment is due
// draws Pay invoice first, and no suspension fact line: the grant delivers, so
// the entitlements are not what is suspended.
func (t *TierViewModel) setPaidUnderGrant(ladderID string, paying payingCard) {
//
// move is the decision's answer for the paid tier (rule 3a.1): when it is an
// enabled Resume, the card draws Resume plan (design D20) with the
// confirmation of Copy M11, {price} the subscription's own price. The move
// never goes to setMove, so no generic control is drawn from it.
func (t *TierViewModel) setPaidUnderGrant(ladderID string, paying payingCard, move plans.Move) {
row := paying.row
t.PaidUnderGrant = true
t.GrantedName = paying.grantedName
@@ -95,6 +101,23 @@ func (t *TierViewModel) setPaidUnderGrant(ladderID string, paying payingCard) {
default:
t.CanCancel = true
}
if move.Allows(plans.Resume) {
t.setResume(row)
}
}
// setResume offers Resume plan with Copy M11's confirmation: "Resume {paid}?",
// then "Your subscription replaces the grant." and "Nothing is charged now;
// then {price}.", the price as the subscription's own item states it.
func (t *TierViewModel) setResume(row billing.ListSubscriptionRungsByOrgRow) {
price := recurringPhrase(billing.Price{
UnitAmount: row.UnitAmount,
Currency: row.Currency,
RecurringInterval: row.RecurringInterval,
})
t.CanResume = true
t.ResumeTitle = "Resume " + t.Name + "?"
t.ResumeBody = "Your subscription replaces the grant. Nothing is charged now; then " + price + "."
}
// paidCancelBody is the cancel confirmation on the paid plan's card: Copy M2
@@ -10,7 +10,10 @@ import (
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/plans"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
"github.com/google/uuid"
)
// The paid plan's card states of design D10, as rows: the subscription's row
@@ -19,8 +22,20 @@ import (
var (
paidPeriodEnd = time.Date(2099, time.January, 15, 12, 0, 0, 0, time.UTC)
paidNow = time.Date(2026, time.October, 28, 12, 0, 0, 0, time.UTC)
// grantEnd is the end date of the grant the dated rows run under.
grantEnd = time.Date(2099, time.February, 20, 12, 0, 0, 0, time.UTC)
)
// grantedPosition is the position row of a position a grant holds, with the
// end date given (the zero time: none).
func grantedPosition(until time.Time) entitlements.ListLadderPositionsByPoolRow {
row := entitlements.ListLadderPositionsByPoolRow{GrantID: uuid.NullUUID{UUID: uuid.New(), Valid: true}}
if !until.IsZero() {
row.GrantValidUntil = sql.NullTime{Time: until, Valid: true}
}
return row
}
func paidRow(mutate func(*billing.ListSubscriptionRungsByOrgRow)) billing.ListSubscriptionRungsByOrgRow {
row := billing.ListSubscriptionRungsByOrgRow{
SubscriptionID: "sub-1",
@@ -84,17 +99,41 @@ func TestPaidPlanCardUnderAGrant(t *testing.T) {
{"cancellation scheduled by a commitment", func(r *billing.ListSubscriptionRungsByOrgRow) {
r.CancellationAt = sql.NullTime{Time: paidPeriodEnd.AddDate(0, 1, 0), Valid: true}
}, "Pro", "Paused while Pro is granted", []string{"Keep plan"}},
{"a lower grant", nil, "Basic", "Paused while Basic is granted", []string{"Resume plan", "Cancel plan"}},
{"a lower grant, unconfirmed", unconfirmed, "Basic", renews, []string{"Cancel plan"}},
{"a lower grant, downgrade scheduled", downgrade, "Basic", "Paused while Basic is granted, moves to Basic on Jan 15, 2099", []string{"Keep plan", "Cancel plan"}},
{"own tier, until", nil, "Plus", "Paused while Plus is granted until Feb 20, 2099", []string{"Cancel plan"}},
{"own tier, until, payment due", status("past_due"), "Plus", "Paused while Plus is granted until Feb 20, 2099, payment due", []string{"Pay invoice", "Cancel plan"}},
{"own tier, until, downgrade scheduled", downgrade, "Plus", "Paused while Plus is granted until Feb 20, 2099, moves to Basic on Jan 15, 2099", []string{"Keep plan", "Cancel plan"}},
{"own tier, until, unconfirmed", unconfirmed, "Plus", "$10.00/month, renews Jan 15, 2099", []string{"Cancel plan"}},
{"period unknown", func(r *billing.ListSubscriptionRungsByOrgRow) { r.CurrentPeriodEnd = sql.NullTime{} }, "Pro", "Billing details are still being processed.", nil},
{"paused by Stripe", status("paused"), "Pro", "$10.00/month, paused", nil},
}
// The decision answers Resume for the paid tier of the rows named here,
// and the dated rows run under a grant that ends on grantEnd.
resumes := map[string]bool{"a lower grant": true}
dated := map[string]bool{
"own tier, until": true, "own tier, until, payment due": true,
"own tier, until, downgrade scheduled": true, "own tier, until, unconfirmed": true,
}
set := MemberSet(t)
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
tier := TierViewModel{ProductID: "plus", Name: "Plus", Relation: "upgrade"}
if row.granted == "Plus" {
tier.Relation = "current"
// setCurrent's order: the grant's line first, then the paid card's.
var until time.Time
if dated[row.name] {
until = grantEnd
}
tier.setGranted(grantedPosition(until))
}
tier.setPaidUnderGrant("ladder-1", payingCard{row: paidRow(row.mutate), grantedName: row.granted, now: paidNow})
var move plans.Move
if resumes[row.name] {
move = plans.Move{Kind: plans.Resume, Enabled: true, Relation: plans.Upgrade}
}
tier.setPaidUnderGrant("ladder-1", payingCard{row: paidRow(row.mutate), grantedName: row.granted, now: paidNow}, move)
lvm := LadderViewModel{Name: "Wiki", Enrolled: true, Tiers: []TierViewModel{tier}}
setCancelBodies(&lvm, nil)
doc := htmltest.Parse(t, Render(t, set, "member_plans.html", PlansData{Ladders: []LadderViewModel{lvm}}))
@@ -130,7 +169,7 @@ func TestCancelBodyOnThePaidCard(t *testing.T) {
t.Run(row.name, func(t *testing.T) {
tier := TierViewModel{ProductID: "plus", Name: "Plus", Relation: row.relation}
card := payingCard{row: paidRow(func(r *billing.ListSubscriptionRungsByOrgRow) { r.Status = row.status }), grantedName: row.granted, now: paidNow}
tier.setPaidUnderGrant("ladder-1", card)
tier.setPaidUnderGrant("ladder-1", card, plans.Move{})
lvm := LadderViewModel{Tiers: []TierViewModel{tier}}
setCancelBodies(&lvm, nil)
if got := lvm.Tiers[0].CancelBody; got != row.want {
@@ -145,7 +184,7 @@ func TestCancelBodyOnThePaidCard(t *testing.T) {
// grant keeps delivering past the date.
func TestCancelBadgeOnAPaidCardUnderAGrant(t *testing.T) {
tier := TierViewModel{ProductID: "plus", Name: "Plus", Relation: "upgrade"}
tier.setPaidUnderGrant("ladder-1", payingCard{row: paidRow(nil), grantedName: "Pro", now: paidNow})
tier.setPaidUnderGrant("ladder-1", payingCard{row: paidRow(nil), grantedName: "Pro", now: paidNow}, plans.Move{})
lvm := LadderViewModel{Name: "Wiki", CancelsOn: "Jan 15, 2099", Tiers: []TierViewModel{tier}}
if got := lvm.CancelBadge(); got.Title != "" || got.Label != "Cancels on Jan 15, 2099" {
t.Errorf("cancel badge of the paid card under a grant = %q titled %q, want the label and no title", got.Label, got.Title)
@@ -155,3 +194,16 @@ func TestCancelBadgeOnAPaidCardUnderAGrant(t *testing.T) {
t.Errorf("cancel badge of a held, active plan has no title, want the title kept")
}
}
// TestResumeConfirmation: the paid card's Resume plan states Copy M11, the
// subscription's own price in the second sentence.
func TestResumeConfirmation(t *testing.T) {
tier := TierViewModel{ProductID: "plus", Name: "Plus", Relation: "upgrade"}
move := plans.Move{Kind: plans.Resume, Enabled: true, Relation: plans.Upgrade}
tier.setPaidUnderGrant("ladder-1", payingCard{row: paidRow(func(r *billing.ListSubscriptionRungsByOrgRow) { r.UnitAmount = 3000 }), grantedName: "Basic", now: paidNow}, move)
wantTitle := "Resume Plus?"
wantBody := "Your subscription replaces the grant. Nothing is charged now; then $30.00 a month."
if tier.ResumeTitle != wantTitle || tier.ResumeBody != wantBody {
t.Errorf("resume confirmation = %q, %q, want %q, %q", tier.ResumeTitle, tier.ResumeBody, wantTitle, wantBody)
}
}
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"context"
"errors"
"net/http/httptest"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/fulfillment"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world"
)
// TestRenderPlansAfterMoveAnswersResume: a refusal of the act that the
// decision could not see is the unavailable-plan sentence, and a completed
// resume shows no banner, since the card itself becomes the current card.
func TestRenderPlansAfterMoveAnswersResume(t *testing.T) {
db := dbtest.Open(t)
h := newPlansGoldenHandler(t, db)
w := world.New(t, db)
org := w.Org("Resume", w.OrgType("rsm"), w.Person("Owner"))
const unavailable = "That plan is no longer available. Refresh the page and choose a current plan."
rows := []struct {
name string
err error
want []string
}{
{"the act's refusal", fulfillment.ErrResumeUnavailable, []string{"Error: " + unavailable}},
{"the act's refusal, wrapped", errors.Join(errors.New("resume"), fulfillment.ErrResumeUnavailable), []string{"Error: " + unavailable}},
{"a completed resume", nil, nil},
}
for _, row := range rows {
rec := httptest.NewRecorder()
h.renderPlansAfterMove(rec, context.Background(), org.ID, row.err, "resume")
doc := htmltest.Parse(t, rec.Body.String())
got := doc.Texts(".alert")
if strings.Join(got, "|") != strings.Join(row.want, "|") {
t.Errorf("%s: banners %q, want %q", row.name, got, row.want)
}
}
}
+107
View File
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server_test
// Resume plan posted from the Products page (openspec change
// grant-pauses-subscription-billing, design D20), against a real Postgres and
// the Stripe mock. The member is basicGrantOverPro's: S pays for Pro, a grant of
// Basic holds the ladder, the grant ranks below the paid tier, and the row
// records the console's pause. The route asks the offer decision first
// (resumeOfferFor) and then the act, which makes no Stripe call.
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world"
stripe "github.com/stripe/stripe-go/v81"
)
// postResume posts Resume plan for the ladder and returns what the page said:
// its alerts, with the page's "Error:" label taken off, or "resumed" when it
// shows no alert, sets the entitlements trigger and draws Pro as the current
// card.
func (pm pausedMember) postResume(t *testing.T, ladderID string) string {
t.Helper()
form := url.Values{"ladder_id": {ladderID}}
req := httptest.NewRequest(http.MethodPost, "/partials/member/plans/resume", strings.NewReader(form.Encode())).WithContext(pm.sctx)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
pm.h.PostResume(rec, req)
doc := htmltest.Parse(t, rec.Body.String())
if alerts := doc.Texts(".alert"); len(alerts) > 0 {
return strings.TrimPrefix(strings.Join(alerts, " "), "Error: ")
}
pro := pm.scalarString(t, `SELECT name FROM core.products WHERE product_id = $1`, pm.products["Pro"])
for _, card := range doc.All(".card") {
titles, badges := card.Texts(".card-title"), card.Texts(".badge")
if len(titles) == 1 && titles[0] == pro && len(badges) > 0 && badges[0] == "Current plan" &&
strings.Contains(rec.Header().Get("HX-Trigger"), "entitlements-changed") {
return "resumed"
}
}
return "no alert, but " + pro + " is not the current card or the trigger is not set"
}
// resumePostRow is one posted resume: the fixture's change and the text the
// page holds.
type resumePostRow struct {
name string
prep func(t *testing.T, pm pausedMember)
// ladder names the ladder the post carries; nil posts the member's own.
ladder func(t *testing.T, pm pausedMember) string
want string
}
func checkPostResume(t *testing.T, row resumePostRow) {
t.Helper()
pm := basicGrantOverPro(t)
if row.prep != nil {
row.prep(t, pm)
}
ladder := pm.ladder
if row.ladder != nil {
ladder = row.ladder(t, pm)
}
if got := pm.postResume(t, ladder); !strings.Contains(got, row.want) {
t.Errorf("%s: answered %q, want %q", row.name, got, row.want)
}
}
func TestPostResume(t *testing.T) {
unavailable := unescaped(offerUnavailable)
rows := []resumePostRow{
{name: "resumes", want: "resumed"},
{name: "past due", prep: setStatus(stripe.SubscriptionStatusPastDue), want: offerPayFirst},
{name: "paused", prep: setStatus(stripe.SubscriptionStatusPaused), want: offerPaused},
{name: "already resumed", prep: func(t *testing.T, pm pausedMember) {
if got := pm.postResume(t, pm.ladder); got != "resumed" {
t.Fatalf("the first resume answered %q, want resumed", got)
}
}, want: unescaped(offerAlready)},
{name: "a cancellation scheduled", prep: func(t *testing.T, pm pausedMember) {
pm.mock.Sub.CancelAtPeriodEnd = true
pm.reconcile(t, "test:cancel")
}, want: unavailable},
{name: "a product no longer public", prep: func(t *testing.T, pm pausedMember) {
if _, err := pm.db.Exec(`UPDATE core.products SET is_public = FALSE WHERE product_id = $1`, pm.products["Pro"]); err != nil {
t.Fatalf("make the product private: %v", err)
}
}, want: "resumed"},
{name: "held by another subscription", prep: func(t *testing.T, pm pausedMember) {
pm.holdWithAnotherSubscription(t, "Max")
}, want: unavailable},
{name: "no paying subscription", ladder: func(t *testing.T, pm pausedMember) string {
w := world.New(t, pm.db)
return w.Ladder("Bare", w.Product("Bare")).ID
}, want: unescaped(offerNoSub)},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) { checkPostResume(t, row) })
}
}
+13 -4
View File
@@ -26,6 +26,7 @@ func TestPlanMoveMessageStatesTheTiming(t *testing.T) {
p fulfillment.SwitchPreview
then string
replacesGrant bool
ownTier bool
newPeriodCharge string
want string
}{
@@ -45,19 +46,27 @@ func TestPlanMoveMessageStatesTheTiming(t *testing.T) {
want: "Your subscription replaces the grant. Your card is charged $300.00 now, minus a credit for the unused part of your current plan, then $300.00 a year from today."},
{name: "a new period with a figure", p: fulfillment.SwitchPreview{Available: true, NetAmount: 27032, Currency: "usd"}, then: yearly, replacesGrant: true, newPeriodCharge: "$300.00",
want: "Your subscription replaces the grant. Your card is charged $300.00 now, minus a credit for the unused part of your current plan, then $300.00 a year from today."},
{name: "the granted tier's own card", p: fulfillment.SwitchPreview{Available: true, NetAmount: 900, Currency: "usd"}, then: then, replacesGrant: true, ownTier: true,
want: "Your subscription replaces the grant. Your card is charged $9.00 now for the rest of this period, then $30.00 a month."},
{name: "the granted tier's own card, a new period", p: fulfillment.SwitchPreview{}, then: yearly, replacesGrant: true, ownTier: true, newPeriodCharge: "$300.00",
want: "Your subscription replaces the grant. Your card is charged $300.00 now, minus a credit for the unused part of your current plan, then $300.00 a year from today."},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := string(planMoveMessage("Plus", "Pro", tc.then, tc.replacesGrant, tc.newPeriodCharge, tc.p))
if !strings.Contains(got, "<strong>Switch to Plus?</strong>") || !strings.Contains(got, `<p class="mb-0">`+tc.want+"</p>") {
t.Errorf("message = %s, want the heading and the second paragraph %q", got, tc.want)
got := string(planMoveMessage("Plus", "Pro", tc.then, tc.replacesGrant, tc.ownTier, tc.newPeriodCharge, tc.p))
heading := "<strong>Switch to Plus?</strong>"
if tc.ownTier {
heading = "<strong>Pay for Plus?</strong>"
}
if !strings.Contains(got, heading) || !strings.Contains(got, `<p class="mb-0">`+tc.want+"</p>") {
t.Errorf("%s: message = %s, want the heading %s and the second paragraph %q", tc.name, got, heading, tc.want)
}
if strings.Contains(got, "estimate") || strings.Contains(got, "about") {
t.Errorf("message = %s: the figure is the charge, not an estimate", got)
}
})
}
if got := string(planMoveMessage("<b>", "Pro", then, false, "", fulfillment.SwitchPreview{})); strings.Contains(got, "<b>?") {
if got := string(planMoveMessage("<b>", "Pro", then, false, false, "", fulfillment.SwitchPreview{})); strings.Contains(got, "<b>?") {
t.Errorf("tier names must be escaped: %s", got)
}
}
+55
View File
@@ -551,6 +551,61 @@ func (h *MemberProductsHandler) switchOffer(ctx context.Context, orgID, ladderID
return plans.Move{}, errPlanNotOffered
}
// resumeOfferFor is the one authorization a Resume plan post passes
// (grant-pauses-subscription-billing D20), built as switchOfferFor builds its
// position: it asks the offer decision for the paying product on the posted
// ladder and returns the canonical ladder id when the decision answers Resume.
// A refusal is mapped in this order: no paying subscription while a
// subscription backs the held position is fulfillment.ErrSameTier (a second
// submit meets it once the first has committed, since the resumed subscription
// then holds the rung); no paying subscription otherwise is
// fulfillment.ErrNoActivePaidSubscription; a suspended paying subscription is
// fulfillment.ErrSubscriptionSuspended, or fulfillment.ErrSubscriptionPaused
// when it is paused, because Resume acts on the paying subscription, so its
// payment sentence is true here (unlike a switch's); anything else, which
// includes a subscription that holds the rung over the paying one, is an
// unavailable plan. No member gate applies: Resume keeps the subscription's
// own price. A read that fails is returned.
func (h *MemberProductsHandler) resumeOfferFor(ctx context.Context, orgID, postedLadder string) (string, error) {
ladder, err := uuid.Parse(postedLadder)
if err != nil {
h.Logger.Info("resume refused: the ladder id is not a UUID")
return "", errPlanNotOffered
}
ladderID := ladder.String()
tiers, err := h.BillingQ.ListTiersByLadderWithProducts(ctx, ladderID)
if err != nil {
return "", fmt.Errorf("resume: list tiers for ladder %s: %w", ladderID, err)
}
loader := newPositionLoader(h.BillingQ, h.EntitlementsQ, h.Database, time.Now)
held, err := loader.positions(ctx, orgID)
if err != nil {
return "", fmt.Errorf("resume: %w", err)
}
paying, err := loader.payingOn(ctx, orgID, ladderID, held)
if err != nil {
return "", fmt.Errorf("resume: %w", err)
}
pos, _, err := loader.onLadder(ctx, ladderID, tiers, held[ladderID], paying)
if err != nil {
return "", fmt.Errorf("resume: read the position on ladder %s: %w", ladderID, err)
}
switch {
case paying == nil && pos.Subscribed:
return "", fulfillment.ErrSameTier
case paying == nil:
return "", fulfillment.ErrNoActivePaidSubscription
case plans.Offer(pos, plans.Target{ProductID: paying.ProductID, Listed: true}).Allows(plans.Resume):
return ladderID, nil
case paying.Paused:
return "", fulfillment.ErrSubscriptionPaused
case paying.Suspended:
return "", fulfillment.ErrSubscriptionSuspended
}
h.Logger.Info("resume refused: the offer decision does not offer it", slog.String("ladder_id", ladderID))
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 {
@@ -0,0 +1,414 @@
<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 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">Plus</h4>
<span class="badge text-bg-success">Current plan</span>
</div>
<p class="text-muted small mb-2">Paused while Plus is granted until Feb 20, 2099</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 1 Plan Site</li>
</ul>
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-auto"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/member/plans/cancel"
data-action-method="post"
data-action-target="#member-plans"
data-action-title="Cancel plan"
data-action-body="Your subscription ends on Jan 15, 2099; Plus stays granted."
data-action-confirm-label="Cancel plan"
data-action-style="warning"
data-action-fields='{"ladder_id": "<ladder:Wiki>"}'>Cancel plan</button>
</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">
<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>
</div>
<p class="text-muted small mb-2">$30.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 10 Plan Sites</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Duo
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<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">Plus</h4>
<span class="badge text-bg-success">Current plan</span>
</div>
<p class="text-muted small mb-2">Paused while Plus is granted until Feb 20, 2099</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 1 Plan Site</li>
</ul>
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-auto"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/member/plans/cancel"
data-action-method="post"
data-action-target="#member-plans"
data-action-title="Cancel plan"
data-action-body="Your subscription ends on Jan 15, 2099; Plus stays granted."
data-action-confirm-label="Cancel plan"
data-action-style="warning"
data-action-fields='{"ladder_id": "<ladder:Duo>"}'>Cancel plan</button>
</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,492 @@
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
"Name": "Plus",
"Description": "",
"Rank": 2,
"Relation": "current",
"EntitlementFeatures": [
"1 Plan Site"
],
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "$10.00/month",
"BillingRenewsOn": "Jan 15, 2099",
"BillingPending": false,
"LadderID": "<ladder:Wiki>",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": true,
"CanKeep": false,
"CancelBody": "Your subscription ends on Jan 15, 2099; Plus stays granted.",
"Held": false,
"PaidUnderGrant": true,
"GrantedName": "Plus",
"CollectionPaused": true,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": true,
"GrantedUntil": "Feb 20, 2099"
},
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
"Name": "Pro",
"Description": "",
"Rank": 4,
"Relation": "",
"EntitlementFeatures": [
"10 Plan Sites"
],
"MetadataFeatures": null,
"PriceID": "<price:Pro>",
"Purchasable": true,
"PriceText": "$30.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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
"CancelsOn": ""
},
{
"Name": "Duo",
"SortOrder": 2,
"Tiers": [
{
"ProductID": "<product:Plus>",
"Name": "Plus",
"Description": "",
"Rank": 0,
"Relation": "current",
"EntitlementFeatures": [
"1 Plan Site"
],
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "$10.00/month",
"BillingRenewsOn": "Jan 15, 2099",
"BillingPending": false,
"LadderID": "<ladder:Duo>",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": true,
"CanKeep": false,
"CancelBody": "Your subscription ends on Jan 15, 2099; Plus stays granted.",
"Held": false,
"PaidUnderGrant": true,
"GrantedName": "Plus",
"CollectionPaused": true,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": true,
"GrantedUntil": "Feb 20, 2099"
},
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
"CancelsOn": ""
}
],
"Error": "",
"Success": ""
}
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": true,
"GrantedName": "Plus",
"CollectionPaused": true
"CollectionPaused": true,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": true,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": true,
"GrantedName": "Plus",
"CollectionPaused": true
"CollectionPaused": true,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": true,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -0,0 +1,420 @@
<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 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">Plus</h4>
<span class="badge text-bg-success">Current plan</span>
</div>
<p class="text-muted small mb-2">Granted until Feb 20, 2099</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 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>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Standard>"}'
hx-target="#checkout-error-<ladder:Wiki>-<product:Standard>" hx-swap="innerHTML">Upgrade</button>
<div id="checkout-error-<ladder:Wiki>-<product:Standard>" 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">Pro</h4>
</div>
<p class="text-muted small mb-2">$30.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 10 Plan Sites</li>
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Pro>"}'
hx-target="#checkout-error-<ladder:Wiki>-<product:Pro>" hx-swap="innerHTML">Upgrade</button>
<div id="checkout-error-<ladder:Wiki>-<product:Pro>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
</div>
</div>
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Duo
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<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">Plus</h4>
<span class="badge text-bg-success">Current plan</span>
</div>
<p class="text-muted small mb-2">Granted until Feb 20, 2099</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 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>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Duo Max>"}'
hx-target="#checkout-error-<ladder:Duo>-<product:Duo Max>" hx-swap="innerHTML">Upgrade</button>
<div id="checkout-error-<ladder:Duo>-<product:Duo Max>" class="small text-danger mt-1"></div>
</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,492 @@
{
"Ladders": [
{
"Name": "Wiki",
"SortOrder": 1,
"Tiers": [
{
"ProductID": "<product:Free>",
"Name": "Free",
"Description": "",
"Rank": 0,
"Relation": "downgrade",
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
"Name": "Basic",
"Description": "",
"Rank": 1,
"Relation": "downgrade",
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
"Name": "Plus",
"Description": "",
"Rank": 2,
"Relation": "current",
"EntitlementFeatures": [
"1 Plan Site"
],
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": true,
"GrantedUntil": "Feb 20, 2099"
},
{
"ProductID": "<product:Standard>",
"Name": "Standard",
"Description": "",
"Rank": 3,
"Relation": "upgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Standard>",
"Purchasable": true,
"PriceText": "$20.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Upgrade",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
"Name": "Pro",
"Description": "",
"Rank": 4,
"Relation": "upgrade",
"EntitlementFeatures": [
"10 Plan Sites"
],
"MetadataFeatures": null,
"PriceID": "<price:Pro>",
"Purchasable": true,
"PriceText": "$30.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Upgrade",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
"CancelsOn": ""
},
{
"Name": "Duo",
"SortOrder": 2,
"Tiers": [
{
"ProductID": "<product:Plus>",
"Name": "Plus",
"Description": "",
"Rank": 0,
"Relation": "current",
"EntitlementFeatures": [
"1 Plan Site"
],
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": true,
"GrantedUntil": "Feb 20, 2099"
},
{
"ProductID": "<product:Duo Max>",
"Name": "Duo Max",
"Description": "",
"Rank": 1,
"Relation": "upgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Duo Max>",
"Purchasable": true,
"PriceText": "$40.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Duo>",
"MoveLabel": "Upgrade",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
"CancelsOn": ""
}
],
"Error": "",
"Success": ""
}
@@ -72,6 +72,8 @@
<p class="text-muted small mb-2">Granted</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
@@ -182,6 +184,8 @@
<p class="text-muted small mb-2">Granted</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
+72 -12
View File
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": true,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": true,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
+72 -12
View File
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": true,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -130,6 +130,8 @@
<p class="text-muted small mb-2">Granted</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": true,
"GrantedName": "Pro",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": true,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -133,6 +133,8 @@
<p class="text-muted small mb-2">Granted</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": true,
"GrantedName": "Pro",
"CollectionPaused": true
"CollectionPaused": true,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": true,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -145,6 +145,8 @@
<p class="text-muted small mb-2">Granted</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": true,
"GrantedName": "Pro",
"CollectionPaused": true
"CollectionPaused": true,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": true,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -143,6 +143,8 @@
<p class="text-muted small mb-2">Granted</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": true,
"GrantedName": "Pro",
"CollectionPaused": true
"CollectionPaused": true,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": true,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -141,6 +141,8 @@
<p class="text-muted small mb-2">Granted</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": true,
"GrantedName": "Pro",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": true,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -0,0 +1,404 @@
<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">Paused while Pro is granted</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 1 Plan Site</li>
</ul>
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-auto"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/member/plans/cancel"
data-action-method="post"
data-action-target="#member-plans"
data-action-title="Cancel plan"
data-action-body="Plus ends on Jan 15, 2099; Pro stays granted."
data-action-confirm-label="Cancel plan"
data-action-style="warning"
data-action-fields='{"ladder_id": "<ladder:Wiki>"}'>Cancel plan</button>
</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>
<p class="text-muted small mb-2">Granted until Feb 20, 2099</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 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">&middot; 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,492 @@
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"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": "$10.00/month",
"BillingRenewsOn": "Jan 15, 2099",
"BillingPending": false,
"LadderID": "<ladder:Wiki>",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": true,
"CanKeep": false,
"CancelBody": "Plus ends on Jan 15, 2099; Pro stays granted.",
"Held": false,
"PaidUnderGrant": true,
"GrantedName": "Pro",
"CollectionPaused": true,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": true,
"GrantedUntil": "Feb 20, 2099"
}
],
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
"CancelsOn": ""
}
],
"Error": "",
"Success": ""
}
@@ -141,6 +141,8 @@
<p class="text-muted small mb-2">Granted</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": true,
"GrantedName": "Pro",
"CollectionPaused": true
"CollectionPaused": true,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": true,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": true,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": true,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": true,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
+72 -12
View File
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
+72 -12
View File
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
+72 -12
View File
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -89,6 +89,8 @@
<p class="text-muted small mb-2">Granted</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
@@ -107,7 +109,7 @@
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-get="/partials/member/plans/switch/preview" hx-vals='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Pro>"}'
hx-target="#member-plans" hx-swap="innerHTML">Upgrade</button>
hx-target="#member-plans" hx-swap="innerHTML">Pay for this plan</button>
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": true,
"GrantedName": "Pro",
"CollectionPaused": true
"CollectionPaused": true,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -85,7 +95,7 @@
"MoveEnabled": true,
"MoveKind": "switch",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Upgrade",
"MoveLabel": "Pay for this plan",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
@@ -101,7 +111,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": true,
"GrantedUntil": ""
},
{
"ProductID": "<product:Max>",
@@ -134,7 +149,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -0,0 +1,434 @@
<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 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">Basic</h4>
<span class="badge text-bg-success">Current plan</span>
</div>
<p class="text-muted small mb-2">Granted</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">&middot; 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">Paused while Basic is granted</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"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/member/plans/resume"
data-action-method="post"
data-action-target="#member-plans"
data-action-title="Resume Standard?"
data-action-body="Your subscription replaces the grant. Nothing is charged now; then $20.00 a month."
data-action-confirm-label="Resume plan"
data-action-style="primary"
data-action-fields='{"ladder_id": "<ladder:Wiki>"}'>Resume plan</button>
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-2"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/member/plans/cancel"
data-action-method="post"
data-action-target="#member-plans"
data-action-title="Cancel plan"
data-action-body="Standard ends on Jan 15, 2099; Basic stays granted."
data-action-confirm-label="Cancel plan"
data-action-style="warning"
data-action-fields='{"ladder_id": "<ladder:Wiki>"}'>Cancel plan</button>
</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">Pro</h4>
</div>
<p class="text-muted small mb-2">$30.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 10 Plan Sites</li>
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-get="/partials/member/plans/switch/preview" hx-vals='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Pro>"}'
hx-target="#member-plans" hx-swap="innerHTML">Upgrade</button>
</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">&middot; 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>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Duo Max>"}'
hx-target="#checkout-error-<ladder:Duo>-<product:Duo Max>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Duo>-<product:Duo Max>" class="small text-danger mt-1"></div>
</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,492 @@
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
"Name": "Basic",
"Description": "",
"Rank": 1,
"Relation": "current",
"EntitlementFeatures": null,
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": true,
"GrantedUntil": ""
},
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"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": "$20.00/month",
"BillingRenewsOn": "Jan 15, 2099",
"BillingPending": false,
"LadderID": "<ladder:Wiki>",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": true,
"CanKeep": false,
"CancelBody": "Standard ends on Jan 15, 2099; Basic stays granted.",
"Held": false,
"PaidUnderGrant": true,
"GrantedName": "Basic",
"CollectionPaused": true,
"CanResume": true,
"ResumeTitle": "Resume Standard?",
"ResumeBody": "Your subscription replaces the grant. Nothing is charged now; then $20.00 a month.",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
"Name": "Pro",
"Description": "",
"Rank": 4,
"Relation": "upgrade",
"EntitlementFeatures": [
"10 Plan Sites"
],
"MetadataFeatures": null,
"PriceID": "<price:Pro>",
"Purchasable": true,
"PriceText": "$30.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "switch",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Upgrade",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
"CancelsOn": ""
},
{
"Name": "Duo",
"SortOrder": 2,
"Tiers": [
{
"ProductID": "<product:Plus>",
"Name": "Plus",
"Description": "",
"Rank": 0,
"Relation": "available",
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
"Name": "Duo Max",
"Description": "",
"Rank": 1,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Duo Max>",
"Purchasable": true,
"PriceText": "$40.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Duo>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"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,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
"CancelsOn": ""
}
],
"Error": "",
"Success": ""
}
@@ -402,6 +402,8 @@
<p class="text-muted small mb-2">Granted</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Gone>",
@@ -453,7 +513,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": true,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
@@ -35,7 +35,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Basic>",
@@ -68,7 +73,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Plus>",
@@ -103,7 +113,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Standard>",
@@ -136,7 +151,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Pro>",
@@ -171,7 +191,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -214,7 +239,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Duo Max>",
@@ -247,7 +277,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": true,
@@ -288,7 +323,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Once>",
@@ -321,7 +361,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Unmapped>",
@@ -354,7 +399,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Elsewhere>",
@@ -387,7 +437,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
},
{
"ProductID": "<product:Bare>",
@@ -420,7 +475,12 @@
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
"CollectionPaused": false,
"CanResume": false,
"ResumeTitle": "",
"ResumeBody": "",
"Granted": false,
"GrantedUntil": ""
}
],
"Enrolled": false,
+3
View File
@@ -199,6 +199,9 @@ POST /partials/member/plans/cancel probe=/partials/member/plans/cancel pattern
POST /partials/member/plans/keep probe=/partials/member/plans/keep pattern=POST /partials/member/plans/keep
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
POST /partials/member/plans/resume probe=/partials/member/plans/resume pattern=POST /partials/member/plans/resume
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
POST /partials/member/plans/switch probe=/partials/member/plans/switch pattern=POST /partials/member/plans/switch
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
+3
View File
@@ -199,6 +199,9 @@ POST /partials/member/plans/cancel probe=/partials/member/plans/cancel pattern
POST /partials/member/plans/keep probe=/partials/member/plans/keep pattern=POST /partials/member/plans/keep
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
POST /partials/member/plans/resume probe=/partials/member/plans/resume pattern=POST /partials/member/plans/resume
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
POST /partials/member/plans/switch probe=/partials/member/plans/switch pattern=POST /partials/member/plans/switch
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
@@ -278,7 +278,7 @@ Cancel plan at the period end, as everywhere (decided with the maintainer): Stri
### D12. The switch of a paused subscription (#278)
Offered by D9's rule on the granted tier and every tier above it, and above the paid tier. Under a grant ranked below the paid tier, the granted tier is not above the paid tier, so only the tiers above the paid tier offer the switch, and the paid tier's own card offers Resume plan (D20). On the granted tier's card, the current card, the control renders below its features with the switch's control markup, labelled with the existing "Upgrade" (M6, decided with the maintainer, question 2).
Offered by D9's rule on the granted tier and every tier above it, and above the paid tier. Under a grant ranked below the paid tier, the granted tier is not above the paid tier, so only the tiers above the paid tier offer the switch, and the paid tier's own card offers Resume plan (D20). On the granted tier's card, the current card, the control renders below its features with the switch's control markup, labelled "Pay for this plan" (M6, ruled, question 2), and the tiers above the granted one keep "Upgrade". The move's `OwnTier` is set when the switch's target is the current rung the grant holds; the label and the confirmation's heading read it (M3, M6).
**The call form: two calls**, ruled after the sandbox and an adversarial check of the forms. Stripe refuses `pause_collection` beside `payment_behavior=pending_if_incomplete`, whatever the card (Context), so no single update can both clear the pause and keep the approval path. The switch of a subscription carrying the console's pause is therefore two calls: the console's clear, `collectionParams(collectionClear)` (`pause_collection=` and `metadata[member_console_collection]=`, `collection.go:88-101`), and then `upgradeNow`'s existing update with nothing changed in its form: `items`, `proration_behavior=always_invoice`, `proration_date`, `payment_behavior=pending_if_incomplete`, and `cancel_at_period_end=false` when the flag is set (`plan_change.go:310-328`). The two calls give the invoice the one update gives (Context), so the proration is still not voided, which was the reason the switch was first decided as one Stripe update; the ruling replaces the one update with two calls and keeps the proration. Rejected with the ruling:
- `error_if_incomplete` in one atomic update: Stripe refuses every payment that needs authentication under it (Stripe's documentation), and `upgradeNow` charges on-session (no `OffSession`, `:310-318`), so a member whose card needs 3-D Secure, common under PSD2 for a console deployed anywhere, could never switch away from the paused plan;
@@ -588,10 +588,10 @@ Every sentence a person reads that this change adds or reuses in a new place. Th
| M0 | The paid plan's card, price line, pause confirmed, status `active` or `trialing`, with nothing scheduled or a cancellation scheduled; also under a grant of the subscription's own tier, where it is the current card and inserts ` until <date>` after `is granted` when that grant has an end date (D21) | `Paused while <granted> is granted`, for example `Paused while Pro is granted`, and `Paused while Plus is granted` under a grant of Plus over a Plus subscription, `Paused while Plus is granted until Dec 1, 2026` when that grant ends Dec 1, 2026 | approved; the dated form under a grant of the subscription's own tier by ruling (question 16) |
| M1 | The same, status `past_due` or `unpaid`; no "Entitlements are suspended…" line; Pay invoice then Cancel plan | `Paused while <granted> is granted, payment due`, and under a grant of the subscription's own tier with an end date `Paused while Plus is granted until Dec 1, 2026, payment due` | approved; the dated form under a grant of the subscription's own tier by ruling (question 16) |
| M2 | Cancel plan's confirmation body on the paid plan's card, under a grant of another tier | `<paid> ends on <date>; <granted> stays granted.`, for example `Plus ends on Jan 15, 2099; Pro stays granted.` | approved |
| M3 | The switch's confirmation from a paused subscription: the heading, then the second paragraph's first sentence, then the existing charge sentence | `Switch to <target>?`, then `Your subscription replaces the grant.` followed by `Your card is charged <amount> now for the rest of this period, then <price>.` | approved |
| M3 | The switch's confirmation from a paused subscription: the heading, then the second paragraph's first sentence, then the existing charge sentence | `Switch to <target>?`, then `Your subscription replaces the grant.` followed by `Your card is charged <amount> now for the rest of this period, then <price>.`; on the granted tier's own card the heading reads `Pay for <target>?`, for example `Pay for demo-plan-2?`, and the rest is unchanged | approved; the `Pay for <target>?` heading by ruling |
| M4 | The paid card of a subscription Stripe holds as `paused` | `<price>, paused` and `Entitlements are suspended.`, both existing | approved (kept) |
| M5 | The paid card when the pause is not confirmed | the existing `<price>, renews <date>`, `<price>, moves to <tier> on <date>`, `<price>, ends <date>`, or `<price>, payment due` | reused (question 1 settled: the mirror decides) |
| M6 | The switch control's label on the granted tier's card | `Upgrade`, the existing label | approved |
| M6 | The switch control's label on the granted tier's own card, the current card, and on the tiers above it | `Pay for this plan` on the granted tier's own card; `Upgrade`, the existing label, on every tier above it | approved, by ruling: beside "Current plan", "Upgrade" reads as moving to a plan the member is already on, and the act starts paying for it |
| M7 | M2 or M9 on a card whose payment is due | M2 or M9 followed by the existing ` The unpaid invoice stays due.`, for example `Plus ends on Jan 15, 2099; Pro stays granted. The unpaid invoice stays due.` | approved |
| M8 | The paid plan's card, price line, pause confirmed, a downgrade scheduled before the grant | `Paused while <granted> is granted, moves to <tier> on <date>`, for example `Paused while Pro is granted, moves to Basic on Jan 15, 2099`, and under a grant of the subscription's own tier with an end date `Paused while Plus is granted until Dec 1, 2026, moves to Basic on Nov 15, 2026` | approved; the dated form under a grant of the subscription's own tier by ruling (question 16) |
| M9 | Cancel plan's confirmation body under a grant of the subscription's own tier | `Your subscription ends on <date>; <granted> stays granted.`, for example `Your subscription ends on Jan 15, 2099; Plus stays granted.` | approved |
@@ -706,7 +706,7 @@ A subscription the console paused stays paused under the old binary, which never
**Settled** (1 by the designer, 2 to 16 by ruling):
1. The paid card while the pause is pending or failed: the mirror decides, with the existing lines (M5).
2. The switch's label on the granted tier's card: the existing "Upgrade" (M6).
2. The switch's label on the granted tier's card: "Pay for this plan" on its own current card, with the confirmation heading "Pay for <tier>?" (M3, M6, by ruling); the tiers above keep "Upgrade" and "Switch to <tier>?".
3. A grant ranked below the paid tier: the paid tier's card offers Resume plan (D20, M10, M11), the tiers above it offer the switch, the tiers between offer nothing; a downgrade scheduled before such a grant that lands at or above it takes the ladder and resumes billing at the lower price (D8, F27).
4. Cancel plan's confirmation when payment is due: M2 or M9 followed by the existing " The unpaid invoice stays due." (M7).
5. The paused titles under several grants: "grants hold {ladders}", and "a grant holds" for one grant (O0 to O2).
@@ -11,7 +11,7 @@ On the Products page, on a ladder a grant holds while a live subscription of the
- no `pause_collection` behavior recorded (the pause pending or failed): the existing "{price}, renews {date}", "{price}, moves to {tier} on {date}" or "{price}, ends {date}", or "{price}, payment due" with Pay invoice, with the same controls and no Resume plan;
- status `paused`: the existing "{price}, paused" and "Entitlements are suspended.", and no control.
{granted} SHALL be the name of the product the grant holds on the ladder, the paid tier's own name under a grant of that tier. Under a grant of the subscription's own tier, where the paid tier's card is the current card, each of the three lines that read "Paused while {granted} is granted" SHALL insert " until {date}" right after "is granted" when the holding grant has an end date, {date} as "Every plan card states its cost" states it for the current tier a grant holds: "Paused while Plus is granted until Dec 1, 2026", "Paused while Plus is granted until Dec 1, 2026, payment due" and "Paused while Plus is granted until Dec 1, 2026, moves to {tier} on {date}". Under a grant of another tier those lines SHALL carry no date, which the granted tier's current card states. Cancel plan on that card SHALL cancel the subscription at its period end, and its confirmation's body SHALL read "{paid} ends on {date}; {granted} stays granted." under a grant of another tier and "Your subscription ends on {date}; {granted} stays granted." under a grant of the subscription's own tier, each followed by " The unpaid invoice stays due." when the payment is due, {date} the subscription's period end. Resume plan SHALL open the shared confirmation dialog titled "Resume {paid}?" whose body reads "Your subscription replaces the grant. Nothing is charged now; then {price}.", {price} the subscription's own recurring price, and whose confirm control reads "Resume plan". The granted tier's card and each tier ranked above it, and above the paid tier, SHALL draw the switch the decision offers, labelled "Upgrade"; under a grant ranked below the paid tier, the tiers between them SHALL draw no control. On a ladder the subscription pays for and no plan holds, the paid tier's card SHALL draw no control and none of the paused lines. The page SHALL read the paying subscriptions in one statement per request.
{granted} SHALL be the name of the product the grant holds on the ladder, the paid tier's own name under a grant of that tier. Under a grant of the subscription's own tier, where the paid tier's card is the current card, each of the three lines that read "Paused while {granted} is granted" SHALL insert " until {date}" right after "is granted" when the holding grant has an end date, {date} as "Every plan card states its cost" states it for the current tier a grant holds: "Paused while Plus is granted until Dec 1, 2026", "Paused while Plus is granted until Dec 1, 2026, payment due" and "Paused while Plus is granted until Dec 1, 2026, moves to {tier} on {date}". Under a grant of another tier those lines SHALL carry no date, which the granted tier's current card states. Cancel plan on that card SHALL cancel the subscription at its period end, and its confirmation's body SHALL read "{paid} ends on {date}; {granted} stays granted." under a grant of another tier and "Your subscription ends on {date}; {granted} stays granted." under a grant of the subscription's own tier, each followed by " The unpaid invoice stays due." when the payment is due, {date} the subscription's period end. Resume plan SHALL open the shared confirmation dialog titled "Resume {paid}?" whose body reads "Your subscription replaces the grant. Nothing is charged now; then {price}.", {price} the subscription's own recurring price, and whose confirm control reads "Resume plan". The granted tier's card and each tier ranked above it, and above the paid tier, SHALL draw the switch the decision offers, labelled "Pay for this plan" on the granted tier's own card and "Upgrade" on each tier above it; under a grant ranked below the paid tier, the tiers between them SHALL draw no control. On a ladder the subscription pays for and no plan holds, the paid tier's card SHALL draw no control and none of the paused lines. The page SHALL read the paying subscriptions in one statement per request.
#### Scenario: The paid plan's card while the grant holds
@@ -68,6 +68,11 @@ On the Products page, on a ladder a grant holds while a live subscription of the
- **WHEN** a grant of Pro whose end date is Feb 20, 2099 holds the ladder over the paused Plus subscription
- **THEN** Pro's card, the current card, reads "Granted until Feb 20, 2099", and Plus's card reads "Paused while Pro is granted"
#### Scenario: The granted tier's own card offers to pay for it
- **WHEN** a member opens the Products page of an organization whose subscription to Plus is paused with the console's marker for a Pro grant on the same ladder
- **THEN** Pro's card, the current card, offers the switch labelled "Pay for this plan", and the card of a tier above Pro offers it labelled "Upgrade"
#### Scenario: Resume plan under a lower grant
- **WHEN** a member opens the Products page of an organization whose subscription to Pro is paused with the console's marker for a Basic grant on the same ladder
@@ -169,7 +169,7 @@ The cancellation, keep, resume, switch and preview endpoints, and `SubscriptionB
### Requirement: A subscription paused for a grant is switched to the granted tier or above
A member SHALL be able to switch a subscription that pays on a ladder a grant holds to the granted tier or to any tier ranked above it, when that tier is also ranked above the tier the subscription pays for and the subscription is not suspended, and the subscription's product and that tier are tiers of that ladder alone, through the existing switch endpoint and its preview. A tier whose price is in another currency than the subscription's price SHALL NOT be offered, because Stripe refuses a change of a subscription's currency, and the switch endpoint SHALL refuse it with "That plan is no longer available. Refresh the page and choose a current plan.". A tier priced at another recurring interval SHALL be offered as usual. The switch SHALL be an immediate upgrade, charged at once with `proration_behavior=always_invoice` and `payment_behavior=pending_if_incomplete`, so that a payment that needs the cardholder's approval keeps its approval link. When the subscription Stripe returns carries the console's pause (`pause_collection` set and the marker), whether or not the subscription holds its ladder, the console SHALL clear the pause and remove the marker in a call of its own and SHALL then send the upgrade's update with its form unchanged, because Stripe refuses an `always_invoice` update on a paused subscription and refuses `pause_collection` in an update sent with `pending_if_incomplete`. In that case, before any Stripe write, the console SHALL show the confirmation again with fresh figures when the confirmation's proration moment lies before the subscription's current period, which Stripe refuses; SHALL refuse the switch with "We couldn't complete that change. Try again in a moment." when the subscription's drafts include a renewal Stripe finalizes automatically, the later of its `next_payment_attempt` and `automatically_finalizes_at` falling within ten minutes or already past, or when the drafts cannot be read; and SHALL write a `reconcile_subscription` entry for the subscription whose `next_attempt_at` lies four minutes on, refusing the switch the same way when the entry cannot be written. On every outcome after the clear but an applied update (an error from either call, a declined payment once its invoice is voided, a payment awaiting approval, and a request that ends early), the request SHALL run the outbox's reconcile of the subscription, whose decision pauses the subscription again unless the rule no longer wants it paused; its failure SHALL be logged and SHALL NOT change the member's answer. Each Stripe call from the draft read to that reconcile's last SHALL carry a deadline, the reconcile's as "A reconcile's Stripe calls end when its context does" states, and SHALL NOT end when the member's request does. A marker without a pause SHALL NOT be cleared by the switch, which then sends the upgrade's update alone. A pause without the marker SHALL NOT be cleared, and Stripe's refusal then answers the switch's generic failure. A declined payment SHALL leave the subscription on its tier with nothing charged, as for every upgrade. Once Stripe applies the update, the reconcile SHALL confer the new tier over the grant, recording the grant as replaced by the subscription. The preview of a subscription whose row records any pause SHALL be the existing unavailable preview, with no Stripe call, because Stripe answers no preview for a paused subscription. Its confirmation SHALL read "Switch to {target}?", and its second paragraph SHALL open "Your subscription replaces the grant." before the charge sentence. Toward a price of another recurring interval than the subscription's, the charge sentence SHALL read "Your card is charged {target price} now, minus a credit for the unused part of your current plan, then {then} from today.", {target price} the target price's amount and {then} its recurring price ("$300.00 a year"), whatever the preview answered, because Stripe then charges the new price's full period less the unused time and moves the billing date to the switch.
A member SHALL be able to switch a subscription that pays on a ladder a grant holds to the granted tier or to any tier ranked above it, when that tier is also ranked above the tier the subscription pays for and the subscription is not suspended, and the subscription's product and that tier are tiers of that ladder alone, through the existing switch endpoint and its preview. A tier whose price is in another currency than the subscription's price SHALL NOT be offered, because Stripe refuses a change of a subscription's currency, and the switch endpoint SHALL refuse it with "That plan is no longer available. Refresh the page and choose a current plan.". A tier priced at another recurring interval SHALL be offered as usual. The switch SHALL be an immediate upgrade, charged at once with `proration_behavior=always_invoice` and `payment_behavior=pending_if_incomplete`, so that a payment that needs the cardholder's approval keeps its approval link. When the subscription Stripe returns carries the console's pause (`pause_collection` set and the marker), whether or not the subscription holds its ladder, the console SHALL clear the pause and remove the marker in a call of its own and SHALL then send the upgrade's update with its form unchanged, because Stripe refuses an `always_invoice` update on a paused subscription and refuses `pause_collection` in an update sent with `pending_if_incomplete`. In that case, before any Stripe write, the console SHALL show the confirmation again with fresh figures when the confirmation's proration moment lies before the subscription's current period, which Stripe refuses; SHALL refuse the switch with "We couldn't complete that change. Try again in a moment." when the subscription's drafts include a renewal Stripe finalizes automatically, the later of its `next_payment_attempt` and `automatically_finalizes_at` falling within ten minutes or already past, or when the drafts cannot be read; and SHALL write a `reconcile_subscription` entry for the subscription whose `next_attempt_at` lies four minutes on, refusing the switch the same way when the entry cannot be written. On every outcome after the clear but an applied update (an error from either call, a declined payment once its invoice is voided, a payment awaiting approval, and a request that ends early), the request SHALL run the outbox's reconcile of the subscription, whose decision pauses the subscription again unless the rule no longer wants it paused; its failure SHALL be logged and SHALL NOT change the member's answer. Each Stripe call from the draft read to that reconcile's last SHALL carry a deadline, the reconcile's as "A reconcile's Stripe calls end when its context does" states, and SHALL NOT end when the member's request does. A marker without a pause SHALL NOT be cleared by the switch, which then sends the upgrade's update alone. A pause without the marker SHALL NOT be cleared, and Stripe's refusal then answers the switch's generic failure. A declined payment SHALL leave the subscription on its tier with nothing charged, as for every upgrade. Once Stripe applies the update, the reconcile SHALL confer the new tier over the grant, recording the grant as replaced by the subscription. The preview of a subscription whose row records any pause SHALL be the existing unavailable preview, with no Stripe call, because Stripe answers no preview for a paused subscription. Its confirmation SHALL read "Switch to {target}?", or "Pay for {target}?" when the target is the granted tier's own current card, and its second paragraph SHALL open "Your subscription replaces the grant." before the charge sentence. Toward a price of another recurring interval than the subscription's, the charge sentence SHALL read "Your card is charged {target price} now, minus a credit for the unused part of your current plan, then {then} from today.", {target price} the target price's amount and {then} its recurring price ("$300.00 a year"), whatever the preview answered, because Stripe then charges the new price's full period less the unused time and moves the billing date to the switch.
#### Scenario: A switch to the granted tier's superior
@@ -229,6 +229,11 @@ A member SHALL be able to switch a subscription that pays on a ladder a grant ho
- **WHEN** the member opens the switch to Max, priced monthly as Plus is, from the paused Plus subscription
- **THEN** the confirmation reads "Switch to Max?", and its second paragraph reads "Your subscription replaces the grant. Your card is charged the prorated difference now, then {price}.", because Stripe answers no preview for a paused subscription
#### Scenario: The confirmation on the granted tier's own card reads Pay for
- **WHEN** the member opens the switch from the paused Plus subscription on the card of Pro, the tier the grant holds
- **THEN** the confirmation reads "Pay for Pro?", and its second paragraph reads "Your subscription replaces the grant." before the charge sentence
#### Scenario: The confirmation states the amount while no pause is recorded
- **WHEN** the subscription's row records no pause yet, and the preview answers an amount
@@ -463,13 +463,13 @@ Section 11 is built on #285's commit, which gives a subscription reconcile's Str
## 12. Resume plan when the grant ranks below the paid tier
- [ ] 12.1 `internal/plans/offer.go` (design D20): the kind `Resume Kind = "resume"`, whose doc comment says the paying subscription takes the ladder back from a lower grant at its own price; rule 3a item 1 answers Resume, enabled, relation `Upgrade`, when `GrantHolds`, the held rung ranks below `Paying.Rank`, not `Suspended`, `ConsolePaused`, not `MoveScheduled`, and not `OtherLadders`, and none, reason `PaidHere`, otherwise, with no price rule and no member gate, as Keep plan applies none; the rules' doc comment states item 1's conditions and why each holds.
- [ ] 12.2 Query: `GetSubscriptionResumeFacts` (`internal/entitlements/queries/`, new: a subscription's `status`, `cancel_at_period_end`, `pause_collection_behavior`, `pause_collection_marked`, and `EXISTS` a `core.subscription_scheduled_changes` row of it whose status is `scheduled`). `make sqlc-generate`.
- [ ] 12.3 `internal/entitlements/subscription_resume.go` (new): `ResumeSubscriptionInput{PoolID, LadderID, SubscriptionID, Reason string}`, the refusals `ErrSubscriptionHoldsLadder`, `ErrResumeSuspended`, `ErrResumePaused` and `ErrResumeRefused`, and `ResumeSubscription(ctx, db, in) error`, which runs design D20's seven steps in one `BeginMaterializing` transaction, `LockPools` before any read, each refusal before any write, the conferral through `Confer` (actor `system`, reason `member:resume`, the summed quantity), then `RequestPoolSubscriptionReconciles(ctx, tx, poolID, "")`, then `MaterializePoolEntitlements(ctx, q, poolID)`, because `BeginMaterializing` does not materialize at commit; it refuses a draft product alone. Its doc comment says why it takes no subscription advisory lock and why it makes no Stripe call (design D20).
- [ ] 12.4 `internal/fulfillment/plan_change.go`: `ResumePlan(ctx, db, logger, keyMode, orgID, ladderID, reason string) error` resolves through `resolveSubscriptionOnLadder`, answers `ErrSameTier`, `ErrSubscriptionSuspended`, `ErrSubscriptionPaused` or `ErrPriceUnreachable` before any write as design D20 states, calls `entitlements.ResumeSubscription` through the package variable `resumeSubscription`, which holds it and is `TestResumePlan_MapsTheActsRefusals`'s seam, as its doc comment says, and maps its refusals to `ErrSameTier`, `ErrSubscriptionSuspended`, `ErrSubscriptionPaused` and the new `fulfillment` error `ErrResumeUnavailable` for `entitlements.ErrResumeRefused`. It calls no `ReconcileSubscription`: the outbox entry the act wrote runs the reconcile.
- [ ] 12.5 `internal/server/member_plan_moves.go`: `PostResume` and `resumeOfferFor` as design D20 states, `resumeOfferFor` answering, in this order: no `Paying` while a subscription backs the held position, `ErrSameTier`; no `Paying` otherwise, `ErrNoActivePaidSubscription`; a suspended `Paying`, `ErrSubscriptionSuspended`, or `ErrSubscriptionPaused` when `Paying.Paused`; any other refusal, `errPlanNotOffered`; `member_plans.go`'s `RegisterRoutes` gains `POST /partials/member/plans/resume`; `internal/server/action_triggers.go` declares it (`{Label: "Resume plan", Method: "POST", Path: "/partials/member/plans/resume", Modal: true}`); `renderPlansAfterMove` answers `ErrResumeUnavailable` with the unavailable-plan sentence, and the action `resume` shows no banner, its comment saying why, as `keep`'s does. `internal/server/testdata/routes.golden` and `routes-not-ready.golden` gain the `POST /partials/member/plans/resume` line, rewritten with `-update` and read.
- [ ] 12.6 `internal/server/member_plans.go` and the template: `setPaidUnderGrant` sets `CanResume`, `ResumeTitle` and `ResumeBody` (Copy M10, M11) from the paid card's move, built from the subscription's own price by `recurringPhrase`; `buildTier` hands the paid card's move to it and never to `setMove`; `plansOfferPaidMove` counts a card with `CanResume`. `member_plans.html` draws Resume plan before Cancel plan on a paid-under-grant card through `#confirmActionModal` (`data-action-url="/partials/member/plans/resume"`, `data-action-method="post"`, `data-action-target="#member-plans"`, `data-action-title` M11's heading, `data-action-body` M11's body, `data-action-confirm-label="Resume plan"`, `data-action-style="primary"`, `data-action-fields` with `ladder_id`), with `aria-describedby` on the billing line when it is shown, the markup copied from Cancel plan's (`member_plans.html:112-121`); the template's comment on the card's states names Resume plan.
- [ ] 12.7 Tests:
- [x] 12.1 `internal/plans/offer.go` (design D20): the kind `Resume Kind = "resume"`, whose doc comment says the paying subscription takes the ladder back from a lower grant at its own price; rule 3a item 1 answers Resume, enabled, relation `Upgrade`, when `GrantHolds`, the held rung ranks below `Paying.Rank`, not `Suspended`, `ConsolePaused`, not `MoveScheduled`, and not `OtherLadders`, and none, reason `PaidHere`, otherwise, with no price rule and no member gate, as Keep plan applies none; the rules' doc comment states item 1's conditions and why each holds.
- [x] 12.2 Query: `GetSubscriptionResumeFacts` (`internal/entitlements/queries/`, new: a subscription's `status`, `cancel_at_period_end`, `pause_collection_behavior`, `pause_collection_marked`, and `EXISTS` a `core.subscription_scheduled_changes` row of it whose status is `scheduled`). `make sqlc-generate`.
- [x] 12.3 `internal/entitlements/subscription_resume.go` (new): `ResumeSubscriptionInput{PoolID, LadderID, SubscriptionID, Reason string}`, the refusals `ErrSubscriptionHoldsLadder`, `ErrResumeSuspended`, `ErrResumePaused` and `ErrResumeRefused`, and `ResumeSubscription(ctx, db, in) error`, which runs design D20's seven steps in one `BeginMaterializing` transaction, `LockPools` before any read, each refusal before any write, the conferral through `Confer` (actor `system`, reason `member:resume`, the summed quantity), then `RequestPoolSubscriptionReconciles(ctx, tx, poolID, "")`, then `MaterializePoolEntitlements(ctx, q, poolID)`, because `BeginMaterializing` does not materialize at commit; it refuses a draft product alone. Its doc comment says why it takes no subscription advisory lock and why it makes no Stripe call (design D20).
- [x] 12.4 `internal/fulfillment/plan_change.go`: `ResumePlan(ctx, db, logger, keyMode, orgID, ladderID, reason string) error` resolves through `resolveSubscriptionOnLadder`, answers `ErrSameTier`, `ErrSubscriptionSuspended`, `ErrSubscriptionPaused` or `ErrPriceUnreachable` before any write as design D20 states, calls `entitlements.ResumeSubscription` through the package variable `resumeSubscription`, which holds it and is `TestResumePlan_MapsTheActsRefusals`'s seam, as its doc comment says, and maps its refusals to `ErrSameTier`, `ErrSubscriptionSuspended`, `ErrSubscriptionPaused` and the new `fulfillment` error `ErrResumeUnavailable` for `entitlements.ErrResumeRefused`. It calls no `ReconcileSubscription`: the outbox entry the act wrote runs the reconcile.
- [x] 12.5 `internal/server/member_plan_moves.go`: `PostResume` and `resumeOfferFor` as design D20 states, `resumeOfferFor` answering, in this order: no `Paying` while a subscription backs the held position, `ErrSameTier`; no `Paying` otherwise, `ErrNoActivePaidSubscription`; a suspended `Paying`, `ErrSubscriptionSuspended`, or `ErrSubscriptionPaused` when `Paying.Paused`; any other refusal, `errPlanNotOffered`; `member_plans.go`'s `RegisterRoutes` gains `POST /partials/member/plans/resume`; `internal/server/action_triggers.go` declares it (`{Label: "Resume plan", Method: "POST", Path: "/partials/member/plans/resume", Modal: true}`); `renderPlansAfterMove` answers `ErrResumeUnavailable` with the unavailable-plan sentence, and the action `resume` shows no banner, its comment saying why, as `keep`'s does. `internal/server/testdata/routes.golden` and `routes-not-ready.golden` gain the `POST /partials/member/plans/resume` line, rewritten with `-update` and read.
- [x] 12.6 `internal/server/member_plans.go` and the template: `setPaidUnderGrant` sets `CanResume`, `ResumeTitle` and `ResumeBody` (Copy M10, M11) from the paid card's move, built from the subscription's own price by `recurringPhrase`; `buildTier` hands the paid card's move to it and never to `setMove`; `plansOfferPaidMove` counts a card with `CanResume`. `member_plans.html` draws Resume plan before Cancel plan on a paid-under-grant card through `#confirmActionModal` (`data-action-url="/partials/member/plans/resume"`, `data-action-method="post"`, `data-action-target="#member-plans"`, `data-action-title` M11's heading, `data-action-body` M11's body, `data-action-confirm-label="Resume plan"`, `data-action-style="primary"`, `data-action-fields` with `ladder_id`), with `aria-describedby` on the billing line when it is shown, the markup copied from Cancel plan's (`member_plans.html:112-121`); the template's comment on the card's states names Resume plan.
- [x] 12.7 Tests:
- `TestOffer` rows (`internal/plans/offer_test.go`): `the paid tier above a lower grant` (Resume, enabled, relation Upgrade); `the paid tier above a lower grant, past due` (none, PaidHere); `the paid tier above a lower grant, no console pause` (none, PaidHere); `the paid tier above a lower grant, a move scheduled` (none, PaidHere); `the paid tier above a lower grant, retired` (Resume, enabled, relation Upgrade); `the paid tier above a lower grant, the default holding the rung` (none, PaidHere); `the paid bundle above a lower grant` (none, PaidHere); `the paid tier under a grant of its own tier` (none, PaidHere); `the paid tier below the grant` (none, PaidHere). Each refusal row is the enabled row `the paid tier above a lower grant` with exactly one fact changed, so that each break of 12.8 meets only its own condition. Failure text unchanged.
- `TestResumeSubscription_TakesTheLadderFromTheGrant` (`internal/entitlements/subscription_resume_db_test.go`): the kit's ladder of Basic, Plus, Pro and Max; `Subscribe(o, pro)`, `Issue(o, basic)`, `PauseCollection`; the act confers Pro sourced by the subscription as an upgrade with actor `system` and reason `member:resume`, `core.grant_replacements` pairs the grant with the subscription, and one `reconcile_subscription` entry is written per live subscription of the organization, in id order, and the pool's materialized entitlements equal Pro's. Failure texts: `after the resume, the ladder holds %v, want Pro by the subscription`; `the grant is not recorded as replaced by the subscription`; `the resume wrote entries for %v, want %v`; `after the resume, the pool's entitlements = %v, want Pro's`.
- `TestResumeSubscription_Refusals`, rows through `checkResumeRefusal`: `no pause recorded`; `a pause made in the Dashboard` (`ForeignPause`); `a marker with no pause`; `a grant of the paid tier`; `a grant above the paid tier`; `held by another subscription`; `a cancellation scheduled` (`CancelAtPeriodEnd`); `a downgrade scheduled` (`ScheduleDowngrade`); `past due` (`Suspend`); `paused` (`Suspend`); `canceled` (`EndSubscription`); `a bundle`; `the product drafted` (`Draft`). Each answers its row's refusal and writes no position, no transition and no outbox entry. Failure text: `%s: resume = %v, want %v, with nothing written`.
@@ -488,7 +488,7 @@ Section 11 is built on #285's commit, which gives a subscription reconcile's Str
- `TestPlansOfferPaidMoveGate` gains the case `resumes`, a region whose only money move is a paid card's enabled Resume, which earns the line. Failure text: `an enabled Resume does not earn the line`.
- The `.json` of each of the 30 states that predate this section (every `.json` under `internal/server/testdata/member_plans/` at section 11's commit) is rewritten with `-update` and read; each gains only `CanResume`, `ResumeTitle` and `ResumeBody` at their zero values and item 12.11's fields, and none of those states' `.html` changes but by item 12.11's line in the states that item names, because none has a grant ranked below its paid tier; any other diff is a stop.
- `TestEveryMutationRouteIsDeclared` passes with the declaration.
- [ ] 12.8 Breaks:
- [x] 12.8 Breaks:
- the rank test dropped from item 1: caught by `TestOffer`, `want_new` `Offer(the paid tier under a grant of its own tier) =`;
- the console-pause test dropped from item 1: caught by the same, `want_new` `Offer(the paid tier above a lower grant, no console pause) =`;
- the scheduled-move test dropped from item 1: caught by the same, `want_new` `Offer(the paid tier above a lower grant, a move scheduled) =`;
@@ -512,9 +512,9 @@ Section 11 is built on #285's commit, which gives a subscription reconcile's Str
- `CanResume` not counted in `plansOfferPaidMove`: caught by `TestPlansOfferPaidMoveGate`, `want_new` `an enabled Resume does not earn the line`;
- the handler's decision gate dropped (`PostResume` calling `ResumePlan` directly): caught by `TestPostResume`, `want_new` `held by another subscription: answered`.
- Unseen by any test of the section, named in the commit message: the supersession of a bundle grant's other ladder, which `core.confer` owns, so no line of the act can be broken to change it (`TestResumeSubscription_OverABundleGrantEndsItOnItsOtherLadder` pins the outcome).
- [ ] 12.9 Docs: `docs/stripe.md`'s grant bullet says that under a grant ranked below the paid tier the member resumes the paid tier at its own price, that the console then clears the pause through the outbox, and that nothing is charged until the next renewal, so the rest of a period whose renewal was voided is free, up to one whole period (nearly a year on an annual price), at a moment the member picks, the end edge's cost of design D14; `docs/plan-management.md` says what the member sees on the paid card; `docs/models/plan-ladders-transitions.md` invariant 20 says that the member's Resume plan supersedes a lower grant and is the one act that takes a ladder back for the product a grant displaced; `docs/database-locks.md` gains Resume plan's transaction under the pool row, with no subscription advisory lock (design, Locks and transactions).
- [ ] 12.10 `make screens`: no demo state reaches the paid card; hand captures at 1280 desktop width and 390 mobile width (emulated, `innerWidth` checked) of the Products page in `paid-standard-under-granted-basic`, of the Resume confirmation (M11), and of the page after the resume, on the test stack with the kit's states loaded, are attached to the commit's review.
- [ ] 12.11 Ride-along: the current card a grant holds states the grant's term (design D21, Copy M13, M0, M1, M8; the proposal's small fixes that ride along; `member-product-discovery`, "Every plan card states its cost" and "A paid plan whose ladder a grant holds shows its pause on its own card"). Line numbers are at `84709d96`. In these parts:
- [x] 12.9 Docs: `docs/stripe.md`'s grant bullet says that under a grant ranked below the paid tier the member resumes the paid tier at its own price, that the console then clears the pause through the outbox, and that nothing is charged until the next renewal, so the rest of a period whose renewal was voided is free, up to one whole period (nearly a year on an annual price), at a moment the member picks, the end edge's cost of design D14; `docs/plan-management.md` says what the member sees on the paid card; `docs/models/plan-ladders-transitions.md` invariant 20 says that the member's Resume plan supersedes a lower grant and is the one act that takes a ladder back for the product a grant displaced; `docs/database-locks.md` gains Resume plan's transaction under the pool row, with no subscription advisory lock (design, Locks and transactions).
- [x] 12.10 `make screens`: no demo state reaches the paid card; hand captures at 1280 desktop width and 390 mobile width (emulated, `innerWidth` checked) of the Products page in `paid-standard-under-granted-basic`, of the Resume confirmation (M11), and of the page after the resume, on the test stack with the kit's states loaded, are attached to the commit's review.
- [x] 12.11 Ride-along: the current card a grant holds states the grant's term (design D21, Copy M13, M0, M1, M8; the proposal's small fixes that ride along; `member-product-discovery`, "Every plan card states its cost" and "A paid plan whose ladder a grant holds shows its pause on its own card"). Line numbers are at `84709d96`. In these parts:
- Query: `ListLadderPositionsByPool` (`internal/entitlements/queries/pool_provision_ladders.sql:187-247`) gains `LEFT JOIN core.grants g ON g.grant_id = p.grant_id` and selects `g.valid_until AS grant_valid_until` after `p.purchase_id`; its doc comment gains a sentence: the column is the end date of the grant whose provision holds the position, the extension's after Manage, and the Products page's current card reads it. `make sqlc-generate`; `ListLadderPositionsByPoolRow` gains `GrantValidUntil sql.NullTime`, and no other reader changes.
- View model: `TierViewModel` (`internal/server/member_plans.go`) gains `Granted bool` and `GrantedUntil string` under one doc comment: the current card a grant holds, not the organization type's default (`default_sourced`); `GrantedUntil` is the grant's `valid_until` in `cardDateLayout`, "" for a grant with no end date; a card with no other line draws them as its price line, and the paid plan's card under a grant of its own tier draws `GrantedUntil` inside its paused line (design D10, D21). `(t *TierViewModel) setGranted(pos entitlements.ListLadderPositionsByPoolRow)`, reading no row and no clock, sets `Granted` when `pos.GrantID.Valid && !pos.DefaultSourced`, and `GrantedUntil` when `pos.GrantValidUntil.Valid` as well; its doc comment says why a passed date is still drawn (design D21). `setCurrent` calls it on `facts.Position` after it sets `Relation` and before the paid card's return (`:492-495`), so the paid card under a grant of its own tier carries the date; `buildTier`'s paid branch (`:470-472`) does not call it, so the paid card under a grant of another tier carries none. `resolveCurrentPlanBilling`'s doc comment (`:804-807`) stops saying that nothing renders for a grant's tier: its card reads the grant's line (`setGranted`).
- Template: the current-card chain of `member_plans.html` (`:78-96`) gains, after its last branch, `{{ else if .GrantedUntil }}` drawing `Granted until {{ .GrantedUntil }}` and `{{ else if .Granted }}` drawing `Granted`, each in the chain's `<p class="text-muted small mb-2">`; its three paused branches (`:80-85`) draw `{{ if .GrantedUntil }} until {{ .GrantedUntil }}{{ end }}` right after `is granted`; the comment above the chain says that the current card a grant holds states the grant's term, and that the paid card under a grant of its own tier states it inside its paused line.
@@ -666,3 +666,8 @@ Section 11 is built on #285's commit, which gives a subscription reconcile's Str
- [ ] 13.6 After a deploy applies `00036` and `00037`, re-run `scripts/db-reader-role.sql` on that deployment (`docs/database-management.md`, "Running the script").
- [ ] 13.7 The archive waits for the maintainer's go. Nothing in this change writes to a production host: item 13.3's count is a read under the reader role.
- [ ] 13.8 The answer to the design repository's open question on the cross-source supersession residue (the commercial layer, not conferral, pauses billing, which is the separation that question asked for) is one entry in `status/design-feedback.md`, for the upstream design sync; `design/` is not edited.
- [x] 12.12 Ruling: on the granted tier's own current card the switch reads "Pay for this plan" and its confirmation's heading "Pay for <tier>?", every other card keeping "Upgrade" and "Switch to <tier>?" (design D9 rule 3a item 2, D12, Copy M3, M6; question 2; `member-product-discovery`, "A paid plan whose ladder a grant holds shows its pause on its own card"; `stripe-subscription-management`, the paused switch's confirmation). In these parts:
- Code: `plans.Move` (`internal/plans/offer.go`) gains `OwnTier bool`, set by rule 3a item 2 when the target is the current rung (`a.ProductID == a.Current`) and the move is a switch with `ReplacesGrant`; `moveLabel` (`internal/server/member_plans.go`) answers "Pay for this plan" for it; `planMoveMessage` (`internal/server/member_plan_moves.go`) gains an `ownTier` parameter that words the heading "Pay for <tier>?", and `renderSwitchPreview` passes the move's `OwnTier`. The body is unchanged.
- Tests: `TestOffer` rows set `ownTier` on `the granted tier over a paying subscription`; `TestPlanMoveMessageStatesTheTiming` gains `the granted tier's own card` and `the granted tier's own card, a new period` (heading "Pay for Plus?"); `TestSwitchLabelUnderAGrant` (`internal/server/member_plans_granted_test.go`, no database) reads the control's text for a move with and without `OwnTier`: "Pay for this plan" and "Upgrade", failure text `%s: controls %q, want %q`.
- Golden: `TestMemberPlansGolden` rewritten with `-update`; only `paid-retired-plus-under-granted-pro` changes, its `.html` and `.json` gaining "Pay for this plan" where Pro's current card read "Upgrade". Any other diff is a stop.
- Breaks: `moveLabel` without its `OwnTier` case: caught by `TestSwitchLabelUnderAGrant`, `want_new` `the granted tier's own card: controls`; the heading always "Switch to": caught by `TestPlanMoveMessageStatesTheTiming`, `want_new` `the granted tier's own card: message`.