Name every ladder a grant leaves empty when it supersedes a bundle
#271. A grant on one ladder of a product that is a tier of several ladders keeps ending the subscription's tier on all of them, and the ladders the grant is not on stay empty until it ends; what changes is that every place an operator or a member looks now says so. A new test pins the console's behaviour the strings rest on: the subscription holds nothing anywhere, the other ladder holds no position, the grant delivers, and a reconcile of the subscription is queued, which pauses its billing when it pays for nothing else. The Issue grant form: an option now carries its own hint (forms.Option.Hint, rendered as data-hint, and hint-on-choice.js shows the chosen option's hint or the declared one). A product that would supersede a tier of several ladders names them: "This grant would supersede a tier a subscription pays for on Duo and Wiki; its billing pauses until the grant ends." A product superseding a tier of one ladder keeps the existing warning. An option's hint is not held to the registry's 100-character cap, because the ladder names are the fact. The Not delivering badge on the organization page's Billing card and in the subscriptions list: the ladder's name rides the conferral shapes (ListConferralShapes selects it, Rung.LadderName), and the title adds "; nothing is delivered on {ladders}" for the ladders of the tier on which no live position exists when the page renders: "Billing paused while a grant holds Wiki; nothing is delivered on Duo." A ladder something else holds is named in neither clause. The organization page reads the default pool's positions it already holds; the list reads them with one statement per page (ListLiveLadderIDsBySubscriptionIDs), and a failed read shows "Failed to load deliveries." as the other reads do. Whether a sentence says paused or continues is decided from the deciding displacements only, as the reconcile decides the pause, so a ladder a later subscription holds cannot turn a recorded pause into "Billing continues"; when a named holder is not a grant the sentence reads "Billing paused while other plans hold Duo and Wiki." The member's Products page: on each ladder the grant does not hold, the paid tier's card reads "Paused while Pro is granted on Wiki", or "Billing continues while Pro is granted on Wiki" until Stripe confirms the pause, and draws no control; the controls stay on the card of the ladder the grant holds. docs/models/plan-ladders-transitions.md, docs/plan-management.md and docs/stripe.md say the same. Tests: TestReconcile_AGrantOnOneLadderOfABundlePausesIt, TestSupersededLadders, TestLadderNamesNeedsEveryName, TestRenderHintOnChoiceShowsTheChosenOptionsOwnHint, TestSubscriptionsListNamesEveryLadderOfABundle, TestOrgEnrollmentTwoLadderGolden (two states on their own World), TestGrantHeldLadder, TestPaidPlanCardOnAVacatedLadder, and new rows in TestDisplacedOnPage, TestDisplacementTitle, TestOrgEnrollmentIssueGrantSupersedeHint, TestOrgEnrollmentReadFailures and TestSubscriptionsListDisplacementReadFailures. TestMemberPlansGolden: the seven paid-plus-under-granted-pro states draw Duo's Plus card with the ladder line; the member and organization .json goldens gain the new fields. The grant-hint walkthrough's own-hint step is a subtest that skips, naming its precondition, because no demo state holds a subscription to a product of two ladders. Full suite on reset databases: 5143 passed, 15 skipped, none failed. make screens: no page changed; no demo state holds such a subscription. Hand captures cover the grant form, the badge and the member's cards. Unseen by any test: hint-on-choice.js not setting the line's text; only the browser walkthrough reads it, and its step skips. The hand captures show it. Breaks, all CAUGHT (44): - DisplacedProducts passes over a product of several rungs - displacedOnPage adds no emptied ladder (page rows) - displacedOnPage adds no emptied ladder (golden) - displacedOnPage names only the deciding ladder as held - groupDisplaced keeps an emptied ladder the group names as held - continuingSentence one subscription without the clause - displacedOnPage ignores live (page rows) - displacedOnPage ignores live (list) - pausedSentence uses pausedHolder whatever the holders - displacedOnPage takes grantsOnly over every holder the walk adds - organization page drops the positions read error from the badge - subscriptionDisplacementTitles ignores the live ladder read error - ListLiveLadderIDsBySubscriptionIDs answers no row - ListConferralShapes selects no name (list) - ListConferralShapes selects no name (golden) - RungsByProduct leaves LadderName empty - supersededLadders answers for a product whose superseded provisions each hold one ladder - supersededLadders keeps only the ladders the product is a tier of - ladderNames answers the names it read when one is missing - issuanceProductOptions leaves Option.Hint empty (page) - issuanceProductOptions leaves Option.Hint empty (golden) - issueGrantSupersedeHintOn answers for no ladders - selectOptions does not copy Hint - selectOptions copies Hint without ShowsHint - choiceHint answers the declared hint for a chosen option with its own - HintDefault left empty - HintDefault set when no option carries its own hint - ui_form_field.html without data-hint on the option - ui_form_field.html without data-hint-default on the line - grantHolds without its DefaultSourced test - grantHeldLadder walks rows in place of ladders - grantHeldLadder without its subscription test - setPaidUnderGrant draws the controls on a card with grantLadder - template plain paused branch without the ladder - template payment-due paused branch without the ladder - template moves-to paused branch without the ladder - template without the Billing continues branch - buildLadder without the empty-ladder case - buildLadder takes the heading's cancellation date from a card with grantLadder - pausedSentence one subscription without the clause - pausedSentence several subscriptions without the clause - continuingSentence a count of the account without the clause - continuingSentence several subscriptions without the clause - groupDisplaced leaves the emptied ladders unsorted
This commit is contained in:
@@ -64,7 +64,7 @@ An invariant is a rule the model guarantees everywhere; code that would break on
|
||||
4. **[db]** One live provision per source: per grant, per purchase, and per product within a subscription (a subscription can deliver several products, so uniqueness is per product inside it). A retried conferral cannot double-deliver, and a source that ended can deliver again later. ("Purchase" is the schema's third source arm, reserved for one-time purchases; nothing creates purchase-sourced provisions yet.)
|
||||
5. **[db-code]** Callers never choose a transition type. `core.confer` derives it from ranks: no previous rank means initiate, a higher rank is an upgrade, a lower one a downgrade, and the same rank is a transfer — which can only happen when a different source takes over the position, because the same source re-conferring is a no-op (invariant 13).
|
||||
6. **[db]** Every new transition row names the occupancy episode it belongs to, and operator-attributed rows must carry the acting person's id.
|
||||
7. **[db-code]** Supersession is whole-bundle and atomic: displacement triggers when the new conferral's product shares at least one ladder with an incumbent position, and then *every* rung that incumbent held ends — including rungs on ladders the new product is not on — the incumbent's provision ends, and the replacement with all its rungs is created, inside one `core.confer` call. Supersession ends the incumbent's delivery, not its source: a superseded grant stays a standing decree, and invariant 18 says when it delivers again and invariant 20 says when a superseded subscription does.
|
||||
7. **[db-code]** Supersession is whole-bundle and atomic: displacement triggers when the new conferral's product shares at least one ladder with an incumbent position, and then *every* rung that incumbent held ends — including rungs on ladders the new product is not on — the incumbent's provision ends, and the replacement with all its rungs is created, inside one `core.confer` call. A subscription's product that is a tier of several ladders, superseded by a grant on some of them, therefore ends on every one of its ladders, and the ladders the grant is not on stay empty until the grant ends: the reconcile leaves the product alone whole while a grant holds one of its ladders (invariant 20), and the default is not restored while the pool holds the grant (invariant 11). The subscription's collection is paused as for a product of one ladder, because the product it pays for is left alone and a grant holds the ladder that decides it (invariant 20), and the act that ends the grant delivers the product on every one of its ladders again (invariant 20). The Issue grant form, the organization page, the subscriptions list and the member's Products page name every one of those ladders. Two alternatives were turned down: delivering the rest of the product on the ladders the grant is not on, which would confer part of a product (#88 records the same whole-bundle end for Checkout and the plan switch), and restoring the default on each emptied ladder, which is #154's question. Supersession ends the incumbent's delivery, not its source: a superseded grant stays a standing decree, and invariant 18 says when it delivers again and invariant 20 says when a superseded subscription does.
|
||||
8. **[db-code]** `core.confer` only activates in the present: a future activation time is rejected (future intent belongs in scheduled changes), and a backdated one may not be earlier than the activation time of the incumbent it supersedes.
|
||||
9. **[db-code]** An ended episode stays ended. The status-sync trigger propagates a provision's suspension or recovery to its episode rows, but never to rows already ended — so recovering from suspension cannot resurrect a superseded rung. A resumption (invariant 18) is a new `core.confer` call from the grant's own row, writing a fresh provision and fresh episodes; no ended row is reopened.
|
||||
10. **[app]** Cancelling to the default is an end plus a separate restoration, never a downgrade: ending never re-mints the baseline itself; each ending entry point (webhook, operator revoke, grant expiry, org-type migration, tier removal) separately calls the guarded restoration. The three that end a plan's delivery (reconcile, through which a subscription ends, operator revocation and grant expiry) first resume the marked grants that plan held, when they ended at least one of its provisions (invariant 18). A restored default and a resumed grant both record "initiate".
|
||||
|
||||
@@ -154,14 +154,14 @@ An operator can issue a grant to move a pool to a specific tier:
|
||||
|
||||
1. Open the organization's page from the **Organizations** list.
|
||||
2. In the **Grants** section, click **Issue grant**. The panel opens with a stacked form: one field per row, each label above its field.
|
||||
3. Choose a **Product**, optionally a **Quantity** and a **Valid until** timestamp, choose a **Reason**, and optionally add a **Note**. A product whose grant would supersede a tier a subscription holds, active or suspended, is marked ⚠ in the **Product** list, and the form's warning says that the subscription's billing pauses until the grant ends.
|
||||
3. Choose a **Product**, optionally a **Quantity** and a **Valid until** timestamp, choose a **Reason**, and optionally add a **Note**. A product whose grant would supersede a tier a subscription holds, active or suspended, is marked ⚠ in the **Product** list, and the form's warning says that the subscription's billing pauses until the grant ends. When the superseded tier is a tier of several ladders, the warning names every one of them: "This grant would supersede a tier a subscription pays for on {ladders}; its billing pauses until the grant ends."
|
||||
4. Tick **Resumes**, whose label sits above its box, when the grant should deliver again after a plan bought or granted over it ends (see [Grants that resume](#grants-that-resume)). It starts unticked, and it is offered for every product, including the organization type's default product.
|
||||
5. Click **Issue grant**.
|
||||
|
||||
The system:
|
||||
- Creates a grant with the chosen reason and the resumption mark, then calls the conferral boundary (`internal/entitlements/conferral.go`) through the operator handler in `internal/server/operator_enrollment.go`: it writes the grant decree, confers the position onto the organization's default pool, then materializes the organization's effective entitlements
|
||||
- Registers a **Temporal workflow** to expire the grant at `valid_until` when one is set
|
||||
- Keeps the ladder for a grant that supersedes a tier a subscription pays for: the subscription 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}".
|
||||
- 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, and when the superseded tier is a tier of several ladders the title adds "; nothing is delivered on {ladders}" for the ladders of that tier no plan holds; the subscription delivers again, on every ladder, 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. On each other ladder the subscription pays for, the paid tier's card reads "Paused while {granted tier} is granted on {ladder}", or "Billing continues while {granted tier} is granted on {ladder}" until the pause is confirmed, and draws no control. 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
|
||||
|
||||
+1
-1
@@ -213,7 +213,7 @@ A member moves between the paid tiers of a plan ladder on the Products page, and
|
||||
- **Cancellation.** Every paid plan offers **Cancel plan** on its card. Access continues until the end of the paid period (`cancel_at_period_end`), after which the organization moves to its type's default plan, when one is configured, or leaves the ladder. An upgrade still awaiting the bank's approval survives the cancellation: Stripe keeps its pending change through `cancel_at_period_end`, and paying its invoice later applies the new price while the plan still ends at the period end.
|
||||
- **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. 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.
|
||||
- **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 superseded tier is a tier of several ladders, the grant ends it on every one of them, so the tier delivers nothing anywhere and the console pauses its collection as for one ladder. The title then adds the ladders of that tier no plan holds: "Billing paused while a grant holds {ladder}; nothing is delivered on {ladders}.", with "Billing continues" in its place until Stripe confirms the pause. On each of those ladders the Products page draws the paid tier's card with its pause and no control: "Paused while {granted} is granted on {ladder}", or "Billing continues while {granted} is granted on {ladder}" until Stripe confirms the pause; the controls stay on the card of the ladder the grant holds. 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 every update that would create an invoice on a paused subscription, an `always_invoice` price change and a billing-cycle reset (`billing_cycle_anchor=now`) alike, 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. A confirmation shown before a renewal and sent after it is shown again with fresh figures before any change is sent, because Stripe refuses a `proration_date` from before the current period's start. 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.
|
||||
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
// server renders the line already hidden or shown for the chosen option, so
|
||||
// this script only follows later choices and restores the state after the
|
||||
// browser restores a form's values. The hint joins the select's
|
||||
// aria-describedby while it is shown.
|
||||
// aria-describedby while it is shown. An option can carry its own line in
|
||||
// data-hint: while it is chosen the line's text is that line, and the line's
|
||||
// data-hint-default (the select's declared hint) when the chosen option
|
||||
// carries none. The text is set as text, never markup.
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
@@ -20,6 +23,15 @@
|
||||
var chosen = select.options[select.selectedIndex];
|
||||
var shown = !!chosen && chosen.hasAttribute("data-shows-hint");
|
||||
hint.hidden = !shown;
|
||||
if (shown) {
|
||||
var own = chosen.getAttribute("data-hint");
|
||||
var fallback = hint.getAttribute("data-hint-default");
|
||||
if (own) {
|
||||
hint.textContent = own;
|
||||
} else if (fallback) {
|
||||
hint.textContent = fallback;
|
||||
}
|
||||
}
|
||||
var ids = (select.getAttribute("aria-describedby") || "").split(/\s+/).filter(function (x) {
|
||||
return x && x !== id;
|
||||
});
|
||||
|
||||
@@ -76,6 +76,14 @@
|
||||
offer decision allows it (a grant ranked below the paid tier,
|
||||
design D20), then Cancel plan unless a cancellation is.
|
||||
|
||||
The paid tier's card on a ladder no plan holds, while a grant
|
||||
holds another ladder of the same product (.GrantedLadder), names
|
||||
that ladder in the same lines ("Paused while {granted} is
|
||||
granted on {ladder}", with ", payment due" or ", moves to {tier}
|
||||
on {date}"), and reads "Billing continues while {granted} is
|
||||
granted on {ladder}" for a live status other than `paused`;
|
||||
it draws no control.
|
||||
|
||||
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
|
||||
@@ -85,11 +93,13 @@
|
||||
{{ 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{{ if .GrantedUntil }} until {{ .GrantedUntil }}{{ end }}, moves to {{ .MovesTo }} on {{ .MovesOn }}</p>
|
||||
<p class="text-muted small mb-2">Paused while {{ .GrantedName }} is granted{{ if .GrantedLadder }} on {{ .GrantedLadder }}{{ end }}{{ 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{{ if .GrantedUntil }} until {{ .GrantedUntil }}{{ end }}, payment due</p>
|
||||
<p class="text-muted small mb-2">Paused while {{ .GrantedName }} is granted{{ if .GrantedLadder }} on {{ .GrantedLadder }}{{ end }}{{ 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{{ if .GrantedUntil }} until {{ .GrantedUntil }}{{ end }}</p>
|
||||
<p class="text-muted small mb-2">Paused while {{ .GrantedName }} is granted{{ if .GrantedLadder }} on {{ .GrantedLadder }}{{ end }}{{ if .GrantedUntil }} until {{ .GrantedUntil }}{{ end }}</p>
|
||||
{{ else if .GrantedLadder }}
|
||||
<p class="text-muted small mb-2">Billing continues while {{ .GrantedName }} is granted on {{ .GrantedLadder }}</p>
|
||||
{{ else if .MovesTo }}
|
||||
<p class="text-muted small mb-2">{{ .BillingPriceText }}, moves to {{ .MovesTo }} on {{ .MovesOn }}</p>
|
||||
{{ else if .EndsOn }}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
the rare field whose one-sidedness would surprise a person, its
|
||||
notice. A one-sided field's declared reason is not copy and never
|
||||
renders (maintainer, 2026-09-03). */}}
|
||||
{{ if .Hint }}<div id="{{ .HintID }}" class="form-text"{{ if .HintHidden }} hidden{{ end }}>{{ .Hint }}</div>{{ end }}
|
||||
{{ if .Hint }}<div id="{{ .HintID }}" class="form-text"{{ if .HintDefault }} data-hint-default="{{ .HintDefault }}"{{ end }}{{ if .HintHidden }} hidden{{ end }}>{{ .Hint }}</div>{{ end }}
|
||||
{{ if .Notice }}<div id="{{ .NoticeID }}" class="form-text">{{ .Notice }}</div>{{ end }}
|
||||
{{ if .Error }}<div id="{{ .ErrorID }}" class="invalid-feedback d-block">{{ .Error }}</div>{{ end }}
|
||||
</div>
|
||||
@@ -110,7 +110,7 @@
|
||||
<input class="form-check-input{{ if .Error }} is-invalid{{ end }}" type="checkbox" id="{{ .ControlID }}" name="{{ .Name }}" value="{{ .CheckboxValue }}"{{ if .Checked }} checked{{ end }} {{ .Attrs }}>
|
||||
{{- else if eq .Control "select" -}}
|
||||
<select class="form-select{{ if .Small }} form-select-sm{{ end }}{{ if .Error }} is-invalid{{ end }}" id="{{ .ControlID }}" name="{{ .Name }}" {{ .Attrs }}>
|
||||
{{ range .Options }}<option value="{{ .Value }}"{{ if .Disabled }} disabled{{ end }}{{ if .Selected }} selected{{ end }}{{ if .ShowsHint }} data-shows-hint{{ end }}>{{ .Label }}</option>
|
||||
{{ range .Options }}<option value="{{ .Value }}"{{ if .Disabled }} disabled{{ end }}{{ if .Selected }} selected{{ end }}{{ if .ShowsHint }} data-shows-hint{{ end }}{{ if .Hint }} data-hint="{{ .Hint }}"{{ end }}>{{ .Label }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
{{- else if eq .Control "textarea" -}}
|
||||
|
||||
@@ -9,10 +9,12 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Rung is one ladder a product is a tier of, with the tier's rank.
|
||||
// Rung is one ladder a product is a tier of, with the tier's rank. Only the
|
||||
// operator's titles read LadderName; the rule ignores it.
|
||||
type Rung struct {
|
||||
LadderID string
|
||||
Rank int32
|
||||
LadderID string
|
||||
LadderName string
|
||||
Rank int32
|
||||
}
|
||||
|
||||
// RungsByProduct groups the conferral shapes' rows by product, each product's
|
||||
@@ -28,8 +30,9 @@ func RungsByProduct(rows []ListConferralShapesRow) map[string][]Rung {
|
||||
continue
|
||||
}
|
||||
rungs[r.ProductID] = append(rungs[r.ProductID], Rung{
|
||||
LadderID: r.PlanLadderID.UUID.String(),
|
||||
Rank: r.Rank.Int32,
|
||||
LadderID: r.PlanLadderID.UUID.String(),
|
||||
LadderName: r.LadderName,
|
||||
Rank: r.Rank.Int32,
|
||||
})
|
||||
}
|
||||
return rungs
|
||||
|
||||
@@ -176,40 +176,40 @@ func TestChooseTiers(t *testing.T) {
|
||||
{
|
||||
name: "two tiers of one ladder keep the higher",
|
||||
products: []string{"P", "Q"},
|
||||
rungs: map[string][]Rung{"P": {{"L1", 0}}, "Q": {{"L1", 1}}},
|
||||
rungs: map[string][]Rung{"P": {{LadderID: "L1", Rank: 0}}, "Q": {{LadderID: "L1", Rank: 1}}},
|
||||
kept: []string{"Q"},
|
||||
outranked: map[string]Outrank{"P": {ByProductID: "Q", LadderID: "L1"}},
|
||||
},
|
||||
{
|
||||
name: "tiers of two ladders are both kept",
|
||||
products: []string{"P", "Q"},
|
||||
rungs: map[string][]Rung{"P": {{"L1", 1}}, "Q": {{"L2", 1}}},
|
||||
rungs: map[string][]Rung{"P": {{LadderID: "L1", Rank: 1}}, "Q": {{LadderID: "L2", Rank: 1}}},
|
||||
kept: []string{"P", "Q"},
|
||||
},
|
||||
{
|
||||
name: "a bundle outranks a tier on one of its ladders",
|
||||
products: []string{"X", "Y"},
|
||||
rungs: map[string][]Rung{"X": {{"L1", 3}, {"L2", 1}}, "Y": {{"L2", 2}}},
|
||||
rungs: map[string][]Rung{"X": {{LadderID: "L1", Rank: 3}, {LadderID: "L2", Rank: 1}}, "Y": {{LadderID: "L2", Rank: 2}}},
|
||||
kept: []string{"X"},
|
||||
outranked: map[string]Outrank{"Y": {ByProductID: "X", LadderID: "L2"}},
|
||||
},
|
||||
{
|
||||
name: "a higher tier takes a shared ladder from a lower bundle",
|
||||
products: []string{"Y", "Z"},
|
||||
rungs: map[string][]Rung{"Y": {{"L2", 2}}, "Z": {{"L2", 1}, {"L3", 1}}},
|
||||
rungs: map[string][]Rung{"Y": {{LadderID: "L2", Rank: 2}}, "Z": {{LadderID: "L2", Rank: 1}, {LadderID: "L3", Rank: 1}}},
|
||||
kept: []string{"Y"},
|
||||
outranked: map[string]Outrank{"Z": {ByProductID: "Y", LadderID: "L2"}},
|
||||
},
|
||||
{
|
||||
name: "an add-on is always kept",
|
||||
products: []string{"A", "Q"},
|
||||
rungs: map[string][]Rung{"Q": {{"L1", 0}}},
|
||||
rungs: map[string][]Rung{"Q": {{LadderID: "L1", Rank: 0}}},
|
||||
kept: []string{"A", "Q"},
|
||||
},
|
||||
{
|
||||
name: "equal ranks keep the lower product id",
|
||||
products: []string{"P", "Q"},
|
||||
rungs: map[string][]Rung{"P": {{"L1", 1}}, "Q": {{"L1", 1}}},
|
||||
rungs: map[string][]Rung{"P": {{LadderID: "L1", Rank: 1}}, "Q": {{LadderID: "L1", Rank: 1}}},
|
||||
kept: []string{"P"},
|
||||
outranked: map[string]Outrank{"Q": {ByProductID: "P", LadderID: "L1"}},
|
||||
},
|
||||
|
||||
@@ -344,8 +344,9 @@ func (q *Queries) GetPlanPoolBucket(ctx context.Context, arg GetPlanPoolBucketPa
|
||||
}
|
||||
|
||||
const listConferralShapes = `-- name: ListConferralShapes :many
|
||||
SELECT s.product_id, s.plan_ladder_id, s.rank
|
||||
SELECT s.product_id, s.plan_ladder_id, s.rank, l.name AS ladder_name
|
||||
FROM core.product_conferral_shapes s
|
||||
JOIN core.plan_ladders l ON l.plan_ladder_id = s.plan_ladder_id
|
||||
WHERE s.product_id = ANY($1::uuid[])
|
||||
AND s.plan_ladder_id IS NOT NULL
|
||||
ORDER BY s.product_id, s.plan_ladder_id
|
||||
@@ -355,13 +356,15 @@ type ListConferralShapesRow struct {
|
||||
ProductID string `json:"product_id"`
|
||||
PlanLadderID uuid.NullUUID `json:"plan_ladder_id"`
|
||||
Rank sql.NullInt32 `json:"rank"`
|
||||
LadderName string `json:"ladder_name"`
|
||||
}
|
||||
|
||||
// The ladders and ranks of the products' current shapes
|
||||
// (core.product_conferral_shapes), one row per rung; a product on no ladder
|
||||
// has none. The reconcile reads it under the pool row and the shared rendezvous it
|
||||
// holds; the rendezvous keeps the ranks from changing under it
|
||||
// (docs/database-locks.md, the rendezvous).
|
||||
// (docs/database-locks.md, the rendezvous). The ladder's name is for the
|
||||
// operator's titles; the reconcile ignores it.
|
||||
func (q *Queries) ListConferralShapes(ctx context.Context, productIds []string) ([]ListConferralShapesRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listConferralShapes, pq.Array(productIds))
|
||||
if err != nil {
|
||||
@@ -371,7 +374,12 @@ func (q *Queries) ListConferralShapes(ctx context.Context, productIds []string)
|
||||
items := []ListConferralShapesRow{}
|
||||
for rows.Next() {
|
||||
var i ListConferralShapesRow
|
||||
if err := rows.Scan(&i.ProductID, &i.PlanLadderID, &i.Rank); err != nil {
|
||||
if err := rows.Scan(
|
||||
&i.ProductID,
|
||||
&i.PlanLadderID,
|
||||
&i.Rank,
|
||||
&i.LadderName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
|
||||
@@ -96,6 +96,53 @@ func (q *Queries) ListAccountSubscriptionDisplacements(ctx context.Context, arg
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listLiveLadderIDsBySubscriptionIDs = `-- name: ListLiveLadderIDsBySubscriptionIDs :many
|
||||
SELECT s.subscription_id::uuid AS subscription_id,
|
||||
l.plan_ladder_id::uuid AS plan_ladder_id
|
||||
FROM core.subscriptions s
|
||||
JOIN core.accounts a ON a.billing_account_id = s.billing_account_id
|
||||
JOIN core.resource_pools rp
|
||||
ON rp.org_id = a.org_id AND rp.pool_type = 'default' AND rp.status = 'active'
|
||||
JOIN core.pool_provision_ladders l ON l.pool_id = rp.pool_id
|
||||
WHERE s.subscription_id = ANY($1::uuid[])
|
||||
AND l.status IN ('active', 'suspended')
|
||||
ORDER BY s.subscription_id, l.plan_ladder_id
|
||||
`
|
||||
|
||||
type ListLiveLadderIDsBySubscriptionIDsRow struct {
|
||||
SubscriptionID string `json:"subscription_id"`
|
||||
PlanLadderID string `json:"plan_ladder_id"`
|
||||
}
|
||||
|
||||
// The subscription id and plan ladder id of each live position (the rows
|
||||
// ListLadderPositionsByPool returns: active or suspended, of any source) of
|
||||
// the active default pool of each listed subscription's organization, joined
|
||||
// as ListSubscriptionDisplacementsBySubscriptionIDs joins it. It is one
|
||||
// statement for the operator's subscriptions list, never one per row; the
|
||||
// list's titles name a ladder as emptied only when no position holds it.
|
||||
func (q *Queries) ListLiveLadderIDsBySubscriptionIDs(ctx context.Context, subscriptionIds []string) ([]ListLiveLadderIDsBySubscriptionIDsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listLiveLadderIDsBySubscriptionIDs, pq.Array(subscriptionIds))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListLiveLadderIDsBySubscriptionIDsRow{}
|
||||
for rows.Next() {
|
||||
var i ListLiveLadderIDsBySubscriptionIDsRow
|
||||
if err := rows.Scan(&i.SubscriptionID, &i.PlanLadderID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listLiveSubscriptionsOfPool = `-- name: ListLiveSubscriptionsOfPool :many
|
||||
SELECT s.subscription_id::uuid AS subscription_id
|
||||
FROM core.resource_pools rp
|
||||
|
||||
@@ -270,7 +270,8 @@ type Querier interface {
|
||||
// (core.product_conferral_shapes), one row per rung; a product on no ladder
|
||||
// has none. The reconcile reads it under the pool row and the shared rendezvous it
|
||||
// holds; the rendezvous keeps the ranks from changing under it
|
||||
// (docs/database-locks.md, the rendezvous).
|
||||
// (docs/database-locks.md, the rendezvous). The ladder's name is for the
|
||||
// operator's titles; the reconcile ignores it.
|
||||
ListConferralShapes(ctx context.Context, productIds []string) ([]ListConferralShapesRow, error)
|
||||
ListContributionsByEntitlementID(ctx context.Context, entitlementID string) ([]NumericEntitlementContribution, error)
|
||||
// Returns only grants currently delivering entitlements to a pool in the
|
||||
@@ -447,6 +448,13 @@ type Querier interface {
|
||||
// cancellation and the first scheduled plan switch with a target price are
|
||||
// the ones the page draws, with the change id as the tie-break.
|
||||
ListLadderPositionsByPool(ctx context.Context, poolID string) ([]ListLadderPositionsByPoolRow, error)
|
||||
// The subscription id and plan ladder id of each live position (the rows
|
||||
// ListLadderPositionsByPool returns: active or suspended, of any source) of
|
||||
// the active default pool of each listed subscription's organization, joined
|
||||
// as ListSubscriptionDisplacementsBySubscriptionIDs joins it. It is one
|
||||
// statement for the operator's subscriptions list, never one per row; the
|
||||
// list's titles name a ladder as emptied only when no position holds it.
|
||||
ListLiveLadderIDsBySubscriptionIDs(ctx context.Context, subscriptionIds []string) ([]ListLiveLadderIDsBySubscriptionIDsRow, error)
|
||||
// What holds the live (active|suspended) positions of each listed pool, for
|
||||
// the preview's other-source lines: the preview names at most 50 such pools,
|
||||
// and reads their sources here in one statement.
|
||||
|
||||
@@ -295,9 +295,11 @@ ORDER BY rp.pool_id;
|
||||
-- (core.product_conferral_shapes), one row per rung; a product on no ladder
|
||||
-- has none. The reconcile reads it under the pool row and the shared rendezvous it
|
||||
-- holds; the rendezvous keeps the ranks from changing under it
|
||||
-- (docs/database-locks.md, the rendezvous).
|
||||
SELECT s.product_id, s.plan_ladder_id, s.rank
|
||||
-- (docs/database-locks.md, the rendezvous). The ladder's name is for the
|
||||
-- operator's titles; the reconcile ignores it.
|
||||
SELECT s.product_id, s.plan_ladder_id, s.rank, l.name AS ladder_name
|
||||
FROM core.product_conferral_shapes s
|
||||
JOIN core.plan_ladders l ON l.plan_ladder_id = s.plan_ladder_id
|
||||
WHERE s.product_id = ANY(sqlc.arg(product_ids)::uuid[])
|
||||
AND s.plan_ladder_id IS NOT NULL
|
||||
ORDER BY s.product_id, s.plan_ladder_id;
|
||||
|
||||
@@ -148,6 +148,24 @@ JOIN core.plan_ladders pl ON pl.plan_ladder_id = d.plan_ladder_id::uuid
|
||||
WHERE s.subscription_id = ANY(sqlc.arg(subscription_ids)::uuid[])
|
||||
ORDER BY s.subscription_id, pl.name, d.plan_ladder_id::uuid, d.holder_provision_id::uuid;
|
||||
|
||||
-- name: ListLiveLadderIDsBySubscriptionIDs :many
|
||||
-- The subscription id and plan ladder id of each live position (the rows
|
||||
-- ListLadderPositionsByPool returns: active or suspended, of any source) of
|
||||
-- the active default pool of each listed subscription's organization, joined
|
||||
-- as ListSubscriptionDisplacementsBySubscriptionIDs joins it. It is one
|
||||
-- statement for the operator's subscriptions list, never one per row; the
|
||||
-- list's titles name a ladder as emptied only when no position holds it.
|
||||
SELECT s.subscription_id::uuid AS subscription_id,
|
||||
l.plan_ladder_id::uuid AS plan_ladder_id
|
||||
FROM core.subscriptions s
|
||||
JOIN core.accounts a ON a.billing_account_id = s.billing_account_id
|
||||
JOIN core.resource_pools rp
|
||||
ON rp.org_id = a.org_id AND rp.pool_type = 'default' AND rp.status = 'active'
|
||||
JOIN core.pool_provision_ladders l ON l.pool_id = rp.pool_id
|
||||
WHERE s.subscription_id = ANY(sqlc.arg(subscription_ids)::uuid[])
|
||||
AND l.status IN ('active', 'suspended')
|
||||
ORDER BY s.subscription_id, l.plan_ladder_id;
|
||||
|
||||
-- name: ListLiveSubscriptionsOfPool :many
|
||||
-- The subscriptions of the pool's organization, through its accounts'
|
||||
-- billing accounts, whose Stripe status is live (active, trialing, past_due
|
||||
|
||||
@@ -478,6 +478,10 @@ type Option struct {
|
||||
// ShowsHint marks an option whose choice shows its select's hint
|
||||
// (Field.HintOnChoice); it renders as data-shows-hint.
|
||||
ShowsHint bool
|
||||
// Hint is the line an option that carries ShowsHint shows in place of the
|
||||
// select's declared Hint while it is chosen; it renders as data-hint and
|
||||
// is read only with ShowsHint.
|
||||
Hint string
|
||||
}
|
||||
|
||||
// ChooseOption is the first option of a select whose value must be chosen:
|
||||
@@ -549,7 +553,10 @@ type Field struct {
|
||||
// that chose one. The select names the hint it controls
|
||||
// (data-hint-toggle), and hint-on-choice.js, which the operator shell
|
||||
// loads, follows later choices. The field declares the Hint it shows
|
||||
// and no Notice.
|
||||
// and no Notice. An option that carries ShowsHint can carry its own
|
||||
// Option.Hint, which the line shows in place of the declared Hint while
|
||||
// that option is chosen; the declared Hint is then also the line's
|
||||
// default (data-hint-default).
|
||||
HintOnChoice bool
|
||||
// Notice is the one-sidedness a person would otherwise be surprised
|
||||
// by, as one line under the control: a create-only value that cannot
|
||||
|
||||
@@ -339,6 +339,9 @@ type OptionView struct {
|
||||
Selected bool
|
||||
// ShowsHint renders data-shows-hint on the option.
|
||||
ShowsHint bool
|
||||
// Hint renders data-hint on the option: the line it shows in place of the
|
||||
// declared hint. It is set only for an option that carries ShowsHint.
|
||||
Hint string
|
||||
}
|
||||
|
||||
// RadioView is one rendered radio in a group.
|
||||
@@ -400,8 +403,12 @@ type FieldView struct {
|
||||
// HintHidden renders the hint with the hidden attribute: a
|
||||
// HintOnChoice field whose chosen option does not show it.
|
||||
HintHidden bool
|
||||
Error string
|
||||
ErrorID string
|
||||
// HintDefault is the declared hint of a HintOnChoice field one of whose
|
||||
// options carries its own hint, rendered as data-hint-default so
|
||||
// hint-on-choice.js can restore it; empty otherwise.
|
||||
HintDefault string
|
||||
Error string
|
||||
ErrorID string
|
||||
// Cells are the Table family's middle cells for this row, in the
|
||||
// declaration's Columns order, pre-rendered by the page
|
||||
// (Binding.Cells). Empty in every other family.
|
||||
@@ -992,10 +999,14 @@ func renderFieldAs(formID, suffix, name string, family Family, f Field, b Bindin
|
||||
if o.Selected && o.ShowsHint {
|
||||
fv.HintHidden = false
|
||||
}
|
||||
if o.Hint != "" {
|
||||
fv.HintDefault = f.Hint
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for i := range fv.Options {
|
||||
fv.Options[i].ShowsHint = false
|
||||
fv.Options[i].Hint = ""
|
||||
}
|
||||
}
|
||||
describedBy := describeField(&fv, wrapper, f, b)
|
||||
@@ -1089,7 +1100,11 @@ func selectOptions(opts []Option, value string, present bool) []OptionView {
|
||||
for _, o := range opts {
|
||||
selected := present && o.Value == value
|
||||
chosen = chosen || selected
|
||||
out = append(out, OptionView{Value: o.Value, Label: o.Label, Disabled: o.Disabled, Selected: selected, ShowsHint: o.ShowsHint})
|
||||
v := OptionView{Value: o.Value, Label: o.Label, Disabled: o.Disabled, Selected: selected, ShowsHint: o.ShowsHint}
|
||||
if o.ShowsHint {
|
||||
v.Hint = o.Hint
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
if !chosen && len(out) > 0 && !present {
|
||||
out[0].Selected = true
|
||||
@@ -1106,13 +1121,19 @@ func radioOptions(opts []Option, wrapper, value string) []RadioView {
|
||||
return out
|
||||
}
|
||||
|
||||
// choiceHint is a HintOnChoice field's hint when an option the select
|
||||
// offers shows it, and "" when none does, so the field renders no line.
|
||||
// choiceHint is a HintOnChoice field's hint: the chosen option's own hint
|
||||
// when it shows one, the declared hint when another option the select offers
|
||||
// shows it, and "" when none does, so the field renders no line.
|
||||
func choiceHint(hint string, opts []OptionView) string {
|
||||
shown := false
|
||||
for _, o := range opts {
|
||||
if o.ShowsHint {
|
||||
return hint
|
||||
if o.Selected && o.ShowsHint && o.Hint != "" {
|
||||
return o.Hint
|
||||
}
|
||||
shown = shown || o.ShowsHint
|
||||
}
|
||||
if shown {
|
||||
return hint
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1201,6 +1201,49 @@ func TestRenderRowFieldInlineOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// An option that shows the hint can carry its own line: the option renders it
|
||||
// as data-hint, the line carries the declared hint as its default, and a
|
||||
// refused submission shows the chosen option's line. An option that does not
|
||||
// show the hint renders none, and a select none of whose options carries a line
|
||||
// renders no default (grant-names-every-emptied-ladder D3).
|
||||
func TestRenderHintOnChoiceShowsTheChosenOptionsOwnHint(t *testing.T) {
|
||||
spec := base()
|
||||
spec.Fields[0] = Field{Name: "kind", Label: "Kind", Control: Select, RuntimeOptions: true, HintOnChoice: true, Hint: "B is special."}
|
||||
opts := map[string][]Option{"kind": {
|
||||
{Value: "a", Label: "A"},
|
||||
{Value: "b", Label: "B", ShowsHint: true},
|
||||
{Value: "c", Label: "C", ShowsHint: true, Hint: "C is more special."},
|
||||
{Value: "d", Label: "D", Hint: "D is never shown."},
|
||||
}}
|
||||
refused := func(v string, o map[string][]Option) FieldView {
|
||||
values := NewValues()
|
||||
values.Set("kind", v)
|
||||
return Render(spec, Binding{Mode: ModeSubmission, Values: values, Options: o}).Fields[0]
|
||||
}
|
||||
f := Render(spec, Binding{Mode: ModeUnbound, Options: opts}).Fields[0]
|
||||
if got := f.Options[2].Hint; got != "C is more special." {
|
||||
t.Errorf("option C renders data-hint %q, want %q", got, "C is more special.")
|
||||
}
|
||||
if got := f.Options[1].Hint; got != "" {
|
||||
t.Errorf("option B renders data-hint %q, want none", got)
|
||||
}
|
||||
if got := f.Options[3].Hint; got != "" {
|
||||
t.Errorf("an option that does not show the hint renders data-hint %q, want none", got)
|
||||
}
|
||||
if f.HintDefault != "B is special." {
|
||||
t.Errorf("the hint line's default = %q, want the declared hint", f.HintDefault)
|
||||
}
|
||||
for _, row := range []struct{ value, label, want string }{{"c", "C", "C is more special."}, {"b", "B", "B is special."}} {
|
||||
if g := refused(row.value, opts); g.Hint != row.want || g.HintHidden {
|
||||
t.Errorf("a refused submission that chose %s renders the hint %q hidden %v, want %q shown", row.label, g.Hint, g.HintHidden, row.want)
|
||||
}
|
||||
}
|
||||
plain := map[string][]Option{"kind": {{Value: "a", Label: "A"}, {Value: "b", Label: "B", ShowsHint: true}}}
|
||||
if g := Render(spec, Binding{Mode: ModeUnbound, Options: plain}).Fields[0]; g.HintDefault != "" {
|
||||
t.Errorf("with no option's own hint the line carries default %q, want none", g.HintDefault)
|
||||
}
|
||||
}
|
||||
|
||||
// A HintOnChoice select renders its declared hint hidden unless the chosen
|
||||
// option shows it, names the hint it controls, and renders no hint when no
|
||||
// option it offers shows it.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package fulfillment_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/stripetest"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
|
||||
)
|
||||
|
||||
// alsoOnLadder adds a second plan ladder whose rank-0 tier is the fixture's
|
||||
// product, so the product is a tier of two ladders, and returns the ladder's
|
||||
// id.
|
||||
func alsoOnLadder(t *testing.T, database *sql.DB, fx fixture) string {
|
||||
t.Helper()
|
||||
bq := billing.New(database)
|
||||
ladder, err := bq.CreatePlanLadder(context.Background(), billing.CreatePlanLadderParams{Name: "Bundle other " + fx.sfx, IsActive: true})
|
||||
if err != nil {
|
||||
t.Fatalf("second ladder: %v", err)
|
||||
}
|
||||
if _, err := bq.CreatePlanLadderTier(context.Background(), billing.CreatePlanLadderTierParams{
|
||||
PlanLadderID: ladder.PlanLadderID, ProductID: fx.productID,
|
||||
}); err != nil {
|
||||
t.Fatalf("put the fixture's product on the second ladder: %v", err)
|
||||
}
|
||||
return ladder.PlanLadderID
|
||||
}
|
||||
|
||||
// subscriptionLadders counts the ladders the subscription's live provisions
|
||||
// hold a position on.
|
||||
func subscriptionLadders(t *testing.T, database *sql.DB, fx fixture) int {
|
||||
t.Helper()
|
||||
return scalarInt(t, database, `
|
||||
SELECT count(DISTINCT l.plan_ladder_id)
|
||||
FROM core.pool_provision_ladders l
|
||||
JOIN core.pool_provisions p ON p.provision_id = l.provision_id
|
||||
WHERE p.subscription_id = $1 AND l.status IN ('active', 'suspended')`,
|
||||
coreSubID(t, database, fx.stripeSubID))
|
||||
}
|
||||
|
||||
// T1 (grant-names-every-emptied-ladder D1): a grant of a tier of one ladder of
|
||||
// a subscription's product that is a tier of two ladders ends the product on
|
||||
// both, leaves the other ladder empty and asks for the pause, as for a
|
||||
// product of one ladder.
|
||||
func TestReconcile_AGrantOnOneLadderOfABundlePausesIt(t *testing.T) {
|
||||
database := dbtest.Open(t)
|
||||
fx := seedFixture(t, database)
|
||||
other := alsoOnLadder(t, database, fx)
|
||||
mock := &stripetest.MockBackend{Sub: fx.activeSub(fx.planItem())}
|
||||
t.Cleanup(stripetest.Install(mock))
|
||||
reconcile(t, database, fx.stripeSubID, "test:created")
|
||||
if n := subscriptionLadders(t, database, fx); n != 2 {
|
||||
t.Fatalf("precondition: the subscription holds %d ladders before the grant, want 2", n)
|
||||
}
|
||||
|
||||
prod2, _, _ := secondTier(t, database, fx)
|
||||
g := issueGrant(t, database, fx, prod2, false)
|
||||
if !grantDelivers(t, database, g.GrantID) {
|
||||
t.Fatalf("precondition: the grant does not deliver after it was issued over the subscription")
|
||||
}
|
||||
reconcile(t, database, fx.stripeSubID, "stripe:customer.subscription.updated")
|
||||
|
||||
if n := liveProvisions(t, database, fx); n != 0 {
|
||||
t.Errorf("a grant on one ladder of a bundle: the subscription holds %d live provisions, want 0", n)
|
||||
}
|
||||
if n := scalarInt(t, database, `SELECT count(*) FROM core.pool_provision_ladders WHERE pool_id = $1 AND plan_ladder_id = $2 AND status IN ('active', 'suspended')`,
|
||||
fx.poolID, other); n != 0 {
|
||||
t.Errorf("a grant on one ladder of a bundle: the other ladder holds %d positions, want 0", n)
|
||||
}
|
||||
core := coreSubID(t, database, fx.stripeSubID)
|
||||
if n := reconcileEntries(t, database, core); n != 1 {
|
||||
t.Errorf("a grant on one ladder of a bundle: reconcile_subscription entries for %s = %d, want 1", core, n)
|
||||
}
|
||||
if !grantDelivers(t, database, g.GrantID) {
|
||||
t.Errorf("a grant on one ladder of a bundle: the grant stopped delivering")
|
||||
}
|
||||
}
|
||||
@@ -226,6 +226,12 @@ type TierViewModel struct {
|
||||
// (design D10, D21).
|
||||
Granted bool
|
||||
GrantedUntil string
|
||||
// GrantedLadder is set on the paid tier's card on a ladder no plan holds
|
||||
// while a grant holds another ladder of the same product: the name of
|
||||
// that ladder, and GrantedName the tier the grant holds there. The card
|
||||
// states the subscription's state and draws no control
|
||||
// (grant-names-every-emptied-ladder D4).
|
||||
GrantedLadder string
|
||||
}
|
||||
|
||||
// PaysInvoice reports whether the card offers Pay invoice: a projected plan
|
||||
@@ -344,6 +350,7 @@ func (h *MemberProductsHandler) buildPlansData(ctx context.Context, orgID string
|
||||
if err != nil {
|
||||
return h.plansLoadFailed("failed to list plan ladders", err)
|
||||
}
|
||||
b.ladders = ladders
|
||||
|
||||
// Ladders arrive ordered by (sort_order, name) from the query. A product
|
||||
// that is a tier in multiple ladders renders under each, with its
|
||||
@@ -380,6 +387,8 @@ type plansBuild struct {
|
||||
planByID map[string]billing.Product
|
||||
// positions is the position the organization holds on each ladder.
|
||||
positions map[string]entitlements.ListLadderPositionsByPoolRow
|
||||
// ladders are the plan ladders of the page, in the page's order.
|
||||
ladders []billing.PlanLadder
|
||||
// orgID is the organization the page is built for.
|
||||
orgID string
|
||||
// moves collects the decision for every drawn tier by product, for the
|
||||
@@ -408,6 +417,15 @@ func (h *MemberProductsHandler) buildLadder(ctx context.Context, b *plansBuild,
|
||||
if row, ok := b.loader.payingRowOn(ladder.PlanLadderID); ok {
|
||||
facts.Paid = &payingCard{row: row, grantedName: tierName(tiers, held.ProductID), now: b.loader.now()}
|
||||
}
|
||||
} else if paying != nil && held.ProductID == "" {
|
||||
// A ladder no plan holds, while a grant holds another ladder of the
|
||||
// product the subscription pays for there (a bundle the grant
|
||||
// emptied): the paid card names that ladder.
|
||||
if row, ok := b.loader.payingRowOn(ladder.PlanLadderID); ok {
|
||||
if other, granted, found := grantHeldLadder(row, b.loader.subscriptionRows, b.positions, b.ladders); found {
|
||||
facts.Paid = &payingCard{row: row, grantedName: granted, grantLadder: other, now: b.loader.now()}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lvm := LadderViewModel{
|
||||
@@ -420,7 +438,7 @@ func (h *MemberProductsHandler) buildLadder(ctx context.Context, b *plansBuild,
|
||||
// position to read it from; its date comes from the paying row. The
|
||||
// position's CancelScheduled stays unset: nothing the grant holds is
|
||||
// cancelled.
|
||||
if facts.Paid != nil && lvm.CancelsOn == "" {
|
||||
if facts.Paid != nil && facts.Paid.grantLadder == "" && lvm.CancelsOn == "" {
|
||||
lvm.CancelsOn = facts.Paid.cancelDate()
|
||||
}
|
||||
for _, t := range tiers {
|
||||
|
||||
@@ -13,10 +13,14 @@ import (
|
||||
// payingCard is what the paid plan's card needs beside the offer
|
||||
// decision (grant-pauses-subscription-billing D10): the row of the
|
||||
// subscription that pays for a tier of a ladder a grant holds, and the name
|
||||
// of the tier the grant holds there.
|
||||
// of the tier the grant holds there. grantLadder is set for the card on a
|
||||
// ladder no plan holds, when a grant holds another ladder of the same
|
||||
// product: the name of that ladder, and grantedName is then the tier the
|
||||
// grant holds there (grant-names-every-emptied-ladder D4).
|
||||
type payingCard struct {
|
||||
row billing.ListSubscriptionRungsByOrgRow
|
||||
grantedName string
|
||||
grantLadder string
|
||||
// now decides whether a suspended subscription's scheduled downgrade has
|
||||
// already been applied by Stripe.
|
||||
now time.Time
|
||||
@@ -53,6 +57,11 @@ func (p payingCard) cancelDate() string {
|
||||
// draws Pay invoice first, and no suspension fact line: the grant delivers, so
|
||||
// the entitlements are not what is suspended.
|
||||
//
|
||||
// The card on a ladder no plan holds (paying.grantLadder set) states the
|
||||
// subscription's state the same way, names the ladder the grant holds, and
|
||||
// draws no control: the controls stay on the card of the ladder the grant
|
||||
// holds.
|
||||
//
|
||||
// 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
|
||||
@@ -83,12 +92,20 @@ func (t *TierViewModel) setPaidUnderGrant(ladderID string, paying payingCard, mo
|
||||
t.Suspension = suspensionPaymentDue
|
||||
}
|
||||
t.CollectionPaused = row.PauseCollectionBehavior.Valid
|
||||
t.LadderID = ladderID
|
||||
|
||||
// A suspended subscription's downgrade whose date is not after now has
|
||||
// already been applied by Stripe, so it is left unset, as for a held
|
||||
// position.
|
||||
suspended := t.Suspension != ""
|
||||
if paying.grantLadder != "" {
|
||||
t.GrantedLadder = paying.grantLadder
|
||||
if paying.cancelDate() == "" && row.DowngradeAt.Valid && (!suspended || row.DowngradeAt.Time.After(paying.now)) {
|
||||
t.MovesTo = row.DowngradeProductName.String
|
||||
t.MovesOn = row.DowngradeAt.Time.Format(cardDateLayout)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.LadderID = ladderID
|
||||
switch {
|
||||
case paying.cancelDate() != "":
|
||||
t.EndsOn = paying.cancelDate()
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/plans"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
|
||||
)
|
||||
|
||||
// The card of the tier a subscription pays for on a ladder no plan holds, while
|
||||
// a grant holds another ladder of the same product, as rows
|
||||
// (grant-names-every-emptied-ladder D4).
|
||||
|
||||
type grantHeldLadderRow struct {
|
||||
name string
|
||||
row billing.ListSubscriptionRungsByOrgRow
|
||||
rows []billing.ListSubscriptionRungsByOrgRow
|
||||
held map[string]entitlements.ListLadderPositionsByPoolRow
|
||||
wantLadder string
|
||||
wantGranted string
|
||||
wantOK bool
|
||||
}
|
||||
|
||||
func grantHeldLadderRows() []grantHeldLadderRow {
|
||||
granted := func(name string) entitlements.ListLadderPositionsByPoolRow {
|
||||
pos := grantedPosition(grantEnd)
|
||||
pos.ProductName = name
|
||||
return pos
|
||||
}
|
||||
subscribed := entitlements.ListLadderPositionsByPoolRow{
|
||||
SubscriptionID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, ProductName: "Plus",
|
||||
}
|
||||
defaulted := granted("Free")
|
||||
defaulted.DefaultSourced = true
|
||||
on := func(subscription string, ladders ...string) []billing.ListSubscriptionRungsByOrgRow {
|
||||
var rows []billing.ListSubscriptionRungsByOrgRow
|
||||
for _, l := range ladders {
|
||||
rows = append(rows, billing.ListSubscriptionRungsByOrgRow{SubscriptionID: subscription, ProductID: "plus", PlanLadderID: l})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
own := billing.ListSubscriptionRungsByOrgRow{SubscriptionID: "s1", ProductID: "plus", PlanLadderID: "l-duo"}
|
||||
return []grantHeldLadderRow{
|
||||
{"a grant holds the other ladder", own, on("s1", "l-duo", "l-wiki"),
|
||||
map[string]entitlements.ListLadderPositionsByPoolRow{"l-wiki": granted("Pro")}, "Wiki", "Pro", true},
|
||||
{"nothing holds the other ladder", own, on("s1", "l-duo", "l-wiki"),
|
||||
map[string]entitlements.ListLadderPositionsByPoolRow{}, "", "", false},
|
||||
{"a default position is not a grant", own, on("s1", "l-duo", "l-wiki"),
|
||||
map[string]entitlements.ListLadderPositionsByPoolRow{"l-wiki": defaulted}, "", "", false},
|
||||
{"a subscription holds the other ladder", own, on("s1", "l-duo", "l-wiki"),
|
||||
map[string]entitlements.ListLadderPositionsByPoolRow{"l-wiki": subscribed}, "", "", false},
|
||||
{"two ladders a grant holds", own, on("s1", "l-duo", "l-hosting", "l-wiki"),
|
||||
map[string]entitlements.ListLadderPositionsByPoolRow{"l-hosting": granted("Host"), "l-wiki": granted("Pro")}, "Wiki", "Pro", true},
|
||||
{"another subscription's ladder", own, append(on("s1", "l-duo"), on("s2", "l-wiki")...),
|
||||
map[string]entitlements.ListLadderPositionsByPoolRow{"l-wiki": granted("Pro")}, "", "", false},
|
||||
{"a product of one ladder", own, on("s1", "l-duo"),
|
||||
map[string]entitlements.ListLadderPositionsByPoolRow{"l-wiki": granted("Pro")}, "", "", false},
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantHeldLadder pins which ladder the card on an empty ladder names: the
|
||||
// first of the page's ladders, other than its own, that the subscription pays
|
||||
// for the product on and a grant holds.
|
||||
func TestGrantHeldLadder(t *testing.T) {
|
||||
ladders := []billing.PlanLadder{
|
||||
{PlanLadderID: "l-wiki", Name: "Wiki"},
|
||||
{PlanLadderID: "l-hosting", Name: "Hosting"},
|
||||
{PlanLadderID: "l-duo", Name: "Duo"},
|
||||
}
|
||||
for _, tc := range grantHeldLadderRows() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ladder, granted, ok := grantHeldLadder(tc.row, tc.rows, tc.held, ladders)
|
||||
if ladder != tc.wantLadder || granted != tc.wantGranted || ok != tc.wantOK {
|
||||
t.Errorf("grantHeldLadder(%s) = %q, %q, %v, want %q, %q, %v", tc.name, ladder, granted, ok, tc.wantLadder, tc.wantGranted, tc.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaidPlanCardOnAVacatedLadder draws the paid tier's card on a ladder no
|
||||
// plan holds, as TestPaidPlanCardUnderAGrant draws the card on the ladder the
|
||||
// grant holds, and checks its one line and that it draws no control.
|
||||
func TestPaidPlanCardOnAVacatedLadder(t *testing.T) {
|
||||
downgrade := func(r *billing.ListSubscriptionRungsByOrgRow) {
|
||||
r.DowngradeAt = sql.NullTime{Time: paidPeriodEnd, Valid: true}
|
||||
r.DowngradeProductName = sql.NullString{String: "Basic", Valid: true}
|
||||
}
|
||||
cancels := func(r *billing.ListSubscriptionRungsByOrgRow) { r.CancelAtPeriodEnd = true }
|
||||
unconfirmed := func(r *billing.ListSubscriptionRungsByOrgRow) { r.PauseCollectionBehavior = sql.NullString{} }
|
||||
status := func(s string) func(*billing.ListSubscriptionRungsByOrgRow) {
|
||||
return func(r *billing.ListSubscriptionRungsByOrgRow) { r.Status = s }
|
||||
}
|
||||
both := func(fs ...func(*billing.ListSubscriptionRungsByOrgRow)) func(*billing.ListSubscriptionRungsByOrgRow) {
|
||||
return func(r *billing.ListSubscriptionRungsByOrgRow) {
|
||||
for _, f := range fs {
|
||||
f(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
const (
|
||||
paused = "Paused while Pro is granted on Wiki"
|
||||
continues = "Billing continues while Pro is granted on Wiki"
|
||||
)
|
||||
rows := []struct {
|
||||
name string
|
||||
mutate func(*billing.ListSubscriptionRungsByOrgRow)
|
||||
line string
|
||||
}{
|
||||
{"confirmed", nil, paused},
|
||||
{"confirmed, trialing", status("trialing"), paused},
|
||||
{"payment due", status("past_due"), paused + ", payment due"},
|
||||
{"downgrade scheduled", downgrade, paused + ", moves to Basic on Jan 15, 2099"},
|
||||
{"cancellation scheduled", cancels, paused},
|
||||
{"unconfirmed", unconfirmed, continues},
|
||||
{"unconfirmed, payment due", both(status("past_due"), unconfirmed), continues},
|
||||
{"unconfirmed, cancellation scheduled", both(cancels, unconfirmed), continues},
|
||||
{"unconfirmed, downgrade scheduled", both(downgrade, unconfirmed), continues},
|
||||
{"period unknown", func(r *billing.ListSubscriptionRungsByOrgRow) { r.CurrentPeriodEnd = sql.NullTime{} }, "Billing details are still being processed."},
|
||||
{"paused by Stripe", status("paused"), "$10.00/month, paused"},
|
||||
}
|
||||
set := MemberSet(t)
|
||||
for _, row := range rows {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
tier := TierViewModel{ProductID: "plus", Name: "Plus", Relation: "upgrade"}
|
||||
tier.setPaidUnderGrant("ladder-1", payingCard{row: paidRow(row.mutate), grantedName: "Pro", grantLadder: "Wiki", now: paidNow}, plans.Move{})
|
||||
lvm := LadderViewModel{Name: "Duo", Tiers: []TierViewModel{tier}}
|
||||
setCancelBodies(&lvm, nil)
|
||||
doc := htmltest.Parse(t, Render(t, set, "member_plans.html", PlansData{Ladders: []LadderViewModel{lvm}}))
|
||||
|
||||
line := doc.Texts("p.text-muted.small.mb-2")
|
||||
controls := doc.Texts(".card-body a.btn, .card-body button.btn")
|
||||
if len(line) != 1 || line[0] != row.line || len(controls) != 0 {
|
||||
t.Errorf("%s: the vacated card's price line %q and controls %v, want %q and none", row.name, line, controls, row.line)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -609,7 +609,8 @@ func (h *OperatorPartialsHandler) loadSubscriptionsData(r *http.Request) Subscri
|
||||
}
|
||||
|
||||
// subscriptionDisplacementTitles reads the page's current items, their
|
||||
// conferral shapes and the page's displacements, once each, and returns the
|
||||
// conferral shapes, the page's displacements and the ladders each
|
||||
// subscription's default pool holds a position on, once each, and returns the
|
||||
// Not delivering badge title of each displaced subscription by id. With no
|
||||
// subscription it reads nothing (displaced-subscription-waits D4).
|
||||
func (h *OperatorPartialsHandler) subscriptionDisplacementTitles(ctx context.Context, subs []billing.ListSubscriptionsPageRow) (map[string]string, error) {
|
||||
@@ -633,13 +634,31 @@ func (h *OperatorPartialsHandler) subscriptionDisplacementTitles(ctx context.Con
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list subscription displacements: %w", err)
|
||||
}
|
||||
return subscriptionTitlesFor(subs, items, shapes, rows), nil
|
||||
liveRows, err := h.EntitlementsQ.ListLiveLadderIDsBySubscriptionIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list live ladder ids: %w", err)
|
||||
}
|
||||
return subscriptionTitlesFor(subs, items, shapes, rows, liveLaddersBySubscription(liveRows)), nil
|
||||
}
|
||||
|
||||
// liveLaddersBySubscription groups the live ladder ids by subscription id. It
|
||||
// reads no row.
|
||||
func liveLaddersBySubscription(rows []entitlements.ListLiveLadderIDsBySubscriptionIDsRow) map[string]map[string]bool {
|
||||
live := make(map[string]map[string]bool)
|
||||
for _, r := range rows {
|
||||
if live[r.SubscriptionID] == nil {
|
||||
live[r.SubscriptionID] = make(map[string]bool)
|
||||
}
|
||||
live[r.SubscriptionID][r.PlanLadderID] = true
|
||||
}
|
||||
return live
|
||||
}
|
||||
|
||||
// subscriptionTitlesFor words the badge title of each displaced subscription
|
||||
// of the page, by subscription id. A row is one subscription, so each title
|
||||
// is worded alone, never in the account's form. It reads no row.
|
||||
func subscriptionTitlesFor(subs []billing.ListSubscriptionsPageRow, items []billing.ListCurrentSubscriptionItemsBySubscriptionIDsRow, shapes []entitlements.ListConferralShapesRow, rows []entitlements.ListSubscriptionDisplacementsBySubscriptionIDsRow) map[string]string {
|
||||
// is worded alone, never in the account's form. live is the ladder ids with a
|
||||
// live position, by subscription id. It reads no row.
|
||||
func subscriptionTitlesFor(subs []billing.ListSubscriptionsPageRow, items []billing.ListCurrentSubscriptionItemsBySubscriptionIDsRow, shapes []entitlements.ListConferralShapesRow, rows []entitlements.ListSubscriptionDisplacementsBySubscriptionIDsRow, live map[string]map[string]bool) map[string]string {
|
||||
refs := make([]subscriptionRef, len(subs))
|
||||
for i, s := range subs {
|
||||
refs[i] = subscriptionRef{ID: s.SubscriptionID, Status: s.Status, PauseBehavior: s.PauseCollectionBehavior.String}
|
||||
@@ -648,7 +667,7 @@ func subscriptionTitlesFor(subs []billing.ListSubscriptionsPageRow, items []bill
|
||||
for i, r := range rows {
|
||||
converted[i] = entitlements.ListAccountSubscriptionDisplacementsRow(r)
|
||||
}
|
||||
byID := displacedByID(refs, productsBySubscription(items), entitlements.RungsByProduct(shapes), entitlements.DisplacementsBySubscription(converted))
|
||||
byID := displacedByID(refs, productsBySubscription(items), entitlements.RungsByProduct(shapes), entitlements.DisplacementsBySubscription(converted), live)
|
||||
titles := make(map[string]string, len(byID))
|
||||
for id, d := range byID {
|
||||
titles[id] = displacementTitle(1, []displacedSubscription{d})
|
||||
|
||||
@@ -311,13 +311,18 @@ var grantReasonDomain = []string{"manual", "evaluation", "promotional", "complim
|
||||
// (is_public=false) render with an "internal" badge; SupersedesSubscription
|
||||
// flags a product whose position would displace a subscription-held rung (its
|
||||
// billing pauses until the grant ends, Copy row W), surfaced as a non-blocking
|
||||
// advisory.
|
||||
// advisory. SupersededLadders names every ladder the subscription tiers the
|
||||
// product would supersede hold, when one of those tiers holds two or more
|
||||
// (core.confer ends a superseded provision on every ladder it holds); it is
|
||||
// empty for a product whose superseded tiers each hold one ladder, whose
|
||||
// option reads Copy row W, and when a name could not be read.
|
||||
type IssuanceProductOption struct {
|
||||
ProductID string
|
||||
Name string
|
||||
IsInternal bool
|
||||
DisplayCategory string
|
||||
SupersedesSubscription bool
|
||||
SupersededLadders []string
|
||||
}
|
||||
|
||||
// BillingSummaryViewModel holds the billing snapshot rendered on the per-org
|
||||
@@ -481,54 +486,89 @@ type displacedHolder struct {
|
||||
|
||||
// displacedSubscription is one subscription's displaced ladders and the
|
||||
// positions that hold them. Paused is true when the subscription's recorded
|
||||
// state has a collection pause and every position that holds one of its
|
||||
// displaced ladders is a grant (design D16).
|
||||
// state has a collection pause and every position that decided a product's
|
||||
// displacement is a grant (design D16). Emptied is the ladders of its
|
||||
// displaced products on which no live position exists when the page renders
|
||||
// (grant-names-every-emptied-ladder D2); Ladders and Holders name the others
|
||||
// a displacement row names.
|
||||
type displacedSubscription struct {
|
||||
Ladders []displacedLadder
|
||||
Holders []displacedHolder
|
||||
Emptied []displacedLadder
|
||||
Paused bool
|
||||
}
|
||||
|
||||
// displacedOnPage applies the reconcile's rule to a subscription's current
|
||||
// products: every product is taken as missing (a product with a live
|
||||
// provision is on no displaced ladder, design D2), and the ladder of each
|
||||
// displacement the rule picks is named. A displacement whose ladder no
|
||||
// current product names produces nothing. pauseBehavior is the subscription
|
||||
// row's recorded pause behavior, empty when none is recorded; the subscription
|
||||
// reads as paused when it is set and every displacement picked is held by a
|
||||
// grant. The record decides "paused", never the rule: a pause Stripe has not
|
||||
// confirmed does not read as one. It reads no row.
|
||||
func displacedOnPage(products []string, rungs map[string][]entitlements.Rung, displacements []entitlements.Displacement, pauseBehavior string) displacedSubscription {
|
||||
// provision is on no displaced ladder, design D2). A product the rule leaves
|
||||
// alone delivers nothing on any of its ladders, so each of its rungs is
|
||||
// named, in rung order, unless something else holds it: a ladder with a
|
||||
// displacement row of the subscription names that ladder and the row's
|
||||
// holder, once per ladder; a ladder with no row that is not in live (the ids
|
||||
// of the ladders with a live position when the page renders) is Emptied; a
|
||||
// ladder with no row that is in live is named nowhere. A displacement whose
|
||||
// ladder no current product names produces nothing. pauseBehavior is the
|
||||
// subscription row's recorded pause behavior, empty when none is recorded;
|
||||
// the subscription reads as paused when it is set and every displacement the
|
||||
// rule picked is held by a grant. The record decides "paused", never the
|
||||
// rule: a pause Stripe has not confirmed does not read as one, and the
|
||||
// holders of the ladders the walk adds do not decide it, because the rule's
|
||||
// own picks are what the pause rule reads. It reads no row.
|
||||
func displacedOnPage(products []string, rungs map[string][]entitlements.Rung, displacements []entitlements.Displacement, live map[string]bool, pauseBehavior string) displacedSubscription {
|
||||
left := entitlements.DisplacedProducts(products, rungs, displacements)
|
||||
byLadder := make(map[string]entitlements.Displacement, len(displacements))
|
||||
for _, d := range displacements {
|
||||
if _, seen := byLadder[d.LadderID]; !seen {
|
||||
byLadder[d.LadderID] = d
|
||||
}
|
||||
}
|
||||
var out displacedSubscription
|
||||
named := make(map[string]bool)
|
||||
grantsOnly := true
|
||||
for _, p := range products {
|
||||
d, ok := left[p]
|
||||
decided, ok := left[p]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out.Ladders = append(out.Ladders, displacedLadder{ID: d.LadderID, Name: d.LadderName})
|
||||
out.Holders = append(out.Holders, displacedHolder{ProvisionID: d.HolderProvisionID, Source: d.HolderSource})
|
||||
grantsOnly = grantsOnly && d.HolderSource == "grant"
|
||||
grantsOnly = grantsOnly && decided.HolderSource == "grant"
|
||||
for _, r := range rungs[p] {
|
||||
if named[r.LadderID] {
|
||||
continue
|
||||
}
|
||||
if d, held := byLadder[r.LadderID]; held {
|
||||
named[r.LadderID] = true
|
||||
out.Ladders = append(out.Ladders, displacedLadder{ID: d.LadderID, Name: d.LadderName})
|
||||
out.Holders = append(out.Holders, displacedHolder{ProvisionID: d.HolderProvisionID, Source: d.HolderSource})
|
||||
} else if !live[r.LadderID] {
|
||||
named[r.LadderID] = true
|
||||
out.Emptied = append(out.Emptied, displacedLadder{ID: r.LadderID, Name: r.LadderName})
|
||||
}
|
||||
}
|
||||
}
|
||||
out.Paused = len(out.Ladders) > 0 && pauseBehavior != "" && grantsOnly
|
||||
return out
|
||||
}
|
||||
|
||||
// titleGroup is the distinct ladders and holders of the displaced
|
||||
// subscriptions of one kind, with how many subscriptions there are.
|
||||
// subscriptions of one kind, with how many subscriptions there are. emptied is
|
||||
// the distinct ladders its subscriptions deliver nothing on that no position
|
||||
// of the group's holders names.
|
||||
type titleGroup struct {
|
||||
count int
|
||||
ladders []displacedLadder
|
||||
holders []displacedHolder
|
||||
emptied []displacedLadder
|
||||
}
|
||||
|
||||
// groupDisplaced collects the displaced subscriptions whose Paused equals
|
||||
// paused. The ladders are distinct by id and sorted by name, then id.
|
||||
// paused. The ladders are distinct by id and sorted by name, then id. A ladder
|
||||
// one subscription names as emptied and another names as held is named as held
|
||||
// only.
|
||||
func groupDisplaced(displaced []displacedSubscription, paused bool) titleGroup {
|
||||
var g titleGroup
|
||||
seenLadder := make(map[string]bool)
|
||||
seenHolder := make(map[string]bool)
|
||||
seenEmptied := make(map[string]bool)
|
||||
for _, d := range displaced {
|
||||
if len(d.Ladders) == 0 || d.Paused != paused {
|
||||
continue
|
||||
@@ -546,33 +586,66 @@ func groupDisplaced(displaced []displacedSubscription, paused bool) titleGroup {
|
||||
g.holders = append(g.holders, h)
|
||||
}
|
||||
}
|
||||
for _, l := range d.Emptied {
|
||||
if !seenEmptied[l.ID] {
|
||||
seenEmptied[l.ID] = true
|
||||
g.emptied = append(g.emptied, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.SortFunc(g.ladders, func(a, b displacedLadder) int {
|
||||
g.emptied = slices.DeleteFunc(g.emptied, func(l displacedLadder) bool { return seenLadder[l.ID] })
|
||||
byNameThenID := func(a, b displacedLadder) int {
|
||||
if c := strings.Compare(a.Name, b.Name); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(a.ID, b.ID)
|
||||
})
|
||||
}
|
||||
slices.SortFunc(g.ladders, byNameThenID)
|
||||
slices.SortFunc(g.emptied, byNameThenID)
|
||||
return g
|
||||
}
|
||||
|
||||
// names returns the group's ladder names, joined for a sentence.
|
||||
func (g titleGroup) names() string {
|
||||
names := make([]string, len(g.ladders))
|
||||
for i, l := range g.ladders {
|
||||
return joinLadderNames(g.ladders)
|
||||
}
|
||||
|
||||
// emptiedClause is the clause that ends a sentence before its period for the
|
||||
// ladders the group's subscriptions deliver nothing on and no position of the
|
||||
// holders names: "" when there are none.
|
||||
func (g titleGroup) emptiedClause() string {
|
||||
if len(g.emptied) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "; nothing is delivered on " + joinLadderNames(g.emptied)
|
||||
}
|
||||
|
||||
// joinLadderNames joins ladder names for a sentence.
|
||||
func joinLadderNames(ladders []displacedLadder) string {
|
||||
names := make([]string, len(ladders))
|
||||
for i, l := range ladders {
|
||||
names[i] = l.Name
|
||||
}
|
||||
return joinWithAnd(names)
|
||||
}
|
||||
|
||||
// pausedSentence is the sentence of Copy rows O0 and O1 for the group;
|
||||
// subscriptions is the account's count.
|
||||
// allGrants reports whether every holder is a grant's position.
|
||||
func allGrants(holders []displacedHolder) bool {
|
||||
return !slices.ContainsFunc(holders, func(h displacedHolder) bool { return h.Source != "grant" })
|
||||
}
|
||||
|
||||
// pausedSentence is the sentence of Copy rows O0 and O1 for the group, ending
|
||||
// with the emptied clause; a holder that is not a grant is worded through
|
||||
// displacementHolder (Copy row B6). subscriptions is the account's count.
|
||||
func (g titleGroup) pausedSentence(subscriptions int) string {
|
||||
holder := pausedHolder(g.holders)
|
||||
if subscriptions == 1 {
|
||||
return "Billing paused while " + holder + " " + g.names() + "."
|
||||
if !allGrants(g.holders) {
|
||||
holder = displacementHolder(g.holders)
|
||||
}
|
||||
return fmt.Sprintf("Billing paused on %d of %d subscriptions while %s %s.", g.count, subscriptions, holder, g.names())
|
||||
if subscriptions == 1 {
|
||||
return "Billing paused while " + holder + " " + g.names() + g.emptiedClause() + "."
|
||||
}
|
||||
return fmt.Sprintf("Billing paused on %d of %d subscriptions while %s %s%s.", g.count, subscriptions, holder, g.names(), g.emptiedClause())
|
||||
}
|
||||
|
||||
// continuingSentence is the existing sentence for the group that keeps
|
||||
@@ -582,11 +655,11 @@ func (g titleGroup) continuingSentence(subscriptions int, afterPaused bool) stri
|
||||
holder := displacementHolder(g.holders)
|
||||
switch {
|
||||
case subscriptions == 1:
|
||||
return "Billing continues while " + holder + " " + g.names() + "."
|
||||
return "Billing continues while " + holder + " " + g.names() + g.emptiedClause() + "."
|
||||
case afterPaused:
|
||||
return fmt.Sprintf("Billing continues on %d while %s %s.", g.count, holder, g.names())
|
||||
return fmt.Sprintf("Billing continues on %d while %s %s%s.", g.count, holder, g.names(), g.emptiedClause())
|
||||
}
|
||||
return fmt.Sprintf("Billing continues on %d of %d subscriptions while %s %s.", g.count, subscriptions, holder, g.names())
|
||||
return fmt.Sprintf("Billing continues on %d of %d subscriptions while %s %s%s.", g.count, subscriptions, holder, g.names(), g.emptiedClause())
|
||||
}
|
||||
|
||||
// displacementTitle builds the Not delivering badge's title (Copy rows O0 to
|
||||
@@ -602,9 +675,13 @@ func (g titleGroup) continuingSentence(subscriptions int, afterPaused bool) stri
|
||||
//
|
||||
// and, with nothing paused, the existing "Billing continues while <holder>
|
||||
// <ladders>." and "Billing continues on <k> of <n> subscriptions while
|
||||
// <holder> <ladders>.". A paused sentence's holder is "a grant holds" when one
|
||||
// grant provision holds every ladder it names and "grants hold" when several
|
||||
// do, counted by provision (displacementHolder counts the same way). The
|
||||
// <holder> <ladders>.". A sentence whose group's subscriptions deliver
|
||||
// nothing on a ladder no holder names ends its holder clause with
|
||||
// "; nothing is delivered on <emptied>" before the period. A paused
|
||||
// sentence's holder is "a grant holds" when one grant provision holds every
|
||||
// ladder it names and "grants hold" when several do, counted by provision
|
||||
// (displacementHolder counts the same way); when a holder is not a grant it is
|
||||
// displacementHolder's words ("other plans hold"). The
|
||||
// recorded pause, never the rule, decides "paused" (displacedOnPage). It reads
|
||||
// no row.
|
||||
func displacementTitle(subscriptions int, displaced []displacedSubscription) string {
|
||||
@@ -859,7 +936,7 @@ func (h *OperatorPartialsHandler) IssueGrant(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
products, productsErr := h.loadIssuanceProducts(r, pools)
|
||||
products, productsErr := h.loadIssuanceProducts(r, pools, h.names(r.Context()))
|
||||
if productsErr != "" {
|
||||
h.renderOrgEnrollmentPage(w, r, orgID, "", productsErr)
|
||||
return
|
||||
@@ -1517,27 +1594,36 @@ func (h *OperatorPartialsHandler) loadOrgEnrollmentData(r *http.Request, orgID s
|
||||
h.Logger.Warn("failed to resolve organization owner", slog.Any("error", err), slog.String("org_id", orgID), slog.String("owner_person_id", org.OwnerPersonID))
|
||||
}
|
||||
|
||||
pools := h.loadOrgPools(r, &data, orgID, names)
|
||||
pools, live := h.loadOrgPools(r, &data, orgID, names)
|
||||
h.loadOrgTierChanges(r, &data, orgID, pools, names)
|
||||
h.loadOrgEntitlementChanges(r, &data, orgID, names)
|
||||
|
||||
// Single issuance form (Doc 41): every published, entitlement-set-bearing
|
||||
// product is issuable.
|
||||
data.IssuanceProducts, data.ProductsError = h.loadIssuanceProducts(r, pools)
|
||||
data.IssuanceProducts, data.ProductsError = h.loadIssuanceProducts(r, pools, names)
|
||||
data.IssueGrantForm = issueGrantFormView(orgID, data.IssuanceProducts, forms.ModeRecord, forms.NewValues(), nil, "")
|
||||
|
||||
h.loadOrgBillingSummary(r, &data, orgID, pools)
|
||||
h.loadOrgBillingSummary(r, &data, orgID, pools, live)
|
||||
h.loadOrgMembers(r, &data, orgID)
|
||||
h.loadOrgGrants(r, &data, orgID, names)
|
||||
h.bindGrantsLedger(&data, grantsLedgerEdit{})
|
||||
return data
|
||||
}
|
||||
|
||||
// defaultLadders is the ladder ids on which the organization's active default
|
||||
// pool holds a live position, and the error of that read: the Not delivering
|
||||
// badge's title names a ladder as emptied only when it is not among them.
|
||||
type defaultLadders struct {
|
||||
ladders map[string]bool
|
||||
err error
|
||||
}
|
||||
|
||||
// loadOrgPools fills the Pools panel: every pool of the organization, of any
|
||||
// status and type, with what it delivers and its usage. It returns the
|
||||
// organization's active pools, which the tier changes and the Issue grant
|
||||
// advisory read.
|
||||
func (h *OperatorPartialsHandler) loadOrgPools(r *http.Request, data *OrgEnrollmentData, orgID string, names *operatorNames) []entitlements.ResourcePool {
|
||||
// advisory read, and the ladders the active default pool holds, read from the
|
||||
// positions the panel already reads.
|
||||
func (h *OperatorPartialsHandler) loadOrgPools(r *http.Request, data *OrgEnrollmentData, orgID string, names *operatorNames) ([]entitlements.ResourcePool, defaultLadders) {
|
||||
// Load pools for the org. Two reads: the active-only set the tier changes
|
||||
// and the Issue grant advisory rely on (targeting semantics: "which pool
|
||||
// would conferral use"), and an any-status read for the panel's own
|
||||
@@ -1578,8 +1664,13 @@ func (h *OperatorPartialsHandler) loadOrgPools(r *http.Request, data *OrgEnrollm
|
||||
}
|
||||
}
|
||||
|
||||
defaultID := defaultPoolID(pools)
|
||||
live := defaultLadders{err: fmt.Errorf("the positions of default pool %s were not read with the organization's pools", defaultID)}
|
||||
poolVMs := make([]PoolEnrollmentViewModel, len(allPools))
|
||||
for i, pool := range allPools {
|
||||
if pool.PoolID == defaultID {
|
||||
live = liveLadders(positions[i], positionsErr[i])
|
||||
}
|
||||
vm := PoolEnrollmentViewModel{
|
||||
PoolID: pool.PoolID,
|
||||
PoolName: pool.Name,
|
||||
@@ -1604,7 +1695,20 @@ func (h *OperatorPartialsHandler) loadOrgPools(r *http.Request, data *OrgEnrollm
|
||||
poolVMs[i] = vm
|
||||
}
|
||||
data.Pools = poolVMs
|
||||
return pools
|
||||
return pools, live
|
||||
}
|
||||
|
||||
// liveLadders is the ladder ids of a pool's live positions, or the error of
|
||||
// the read that failed to give them. It reads no row.
|
||||
func liveLadders(positions []entitlements.ListLadderPositionsByPoolRow, err error) defaultLadders {
|
||||
if err != nil {
|
||||
return defaultLadders{err: fmt.Errorf("list ladder positions of the default pool: %w", err)}
|
||||
}
|
||||
ladders := make(map[string]bool, len(positions))
|
||||
for _, p := range positions {
|
||||
ladders[p.PlanLadderID] = true
|
||||
}
|
||||
return defaultLadders{ladders: ladders}
|
||||
}
|
||||
|
||||
// poolDeliveries groups a pool's placements by delivery — a pool can be on
|
||||
@@ -1769,7 +1873,7 @@ func transitionRow(trn entitlements.ListTransitionsByPoolRow, names *operatorNam
|
||||
// pool is the one the displacement badge's title reads
|
||||
// (displaced-subscription-waits D4). With none among them the title is not
|
||||
// read.
|
||||
func (h *OperatorPartialsHandler) loadOrgBillingSummary(r *http.Request, data *OrgEnrollmentData, orgID string, pools []entitlements.ResourcePool) {
|
||||
func (h *OperatorPartialsHandler) loadOrgBillingSummary(r *http.Request, data *OrgEnrollmentData, orgID string, pools []entitlements.ResourcePool, live defaultLadders) {
|
||||
accounts, err := h.BillingQ.ListBillingAccountsByOrgID(r.Context(), orgID)
|
||||
if err == nil && len(accounts) > 0 {
|
||||
acct := accounts[0]
|
||||
@@ -1780,7 +1884,7 @@ func (h *OperatorPartialsHandler) loadOrgBillingSummary(r *http.Request, data *O
|
||||
}
|
||||
if subs, err := h.BillingQ.GetSubscriptionsByBillingAccountID(r.Context(), acct.BillingAccountID); err == nil {
|
||||
summary.SubscriptionCount, summary.SubscriptionState, summary.SubscriptionNote = summarizeSubscriptions(subs)
|
||||
h.fillDisplacement(r, &summary, orgID, pools, subs)
|
||||
h.fillDisplacement(r, &summary, orgID, pools, live, subs)
|
||||
}
|
||||
if inv, err := h.BillingQ.GetLatestInvoiceByBillingAccountID(r.Context(), acct.BillingAccountID); err == nil {
|
||||
summary.HasInvoice = true
|
||||
@@ -1814,16 +1918,21 @@ func (h *OperatorPartialsHandler) loadOrgBillingSummary(r *http.Request, data *O
|
||||
|
||||
// fillDisplacement sets the card's displacement badge title from three
|
||||
// statements for the account, none per subscription: the current items, the
|
||||
// shapes of their products and the displacements. A failed read logs and sets
|
||||
// shapes of their products and the displacements, and the default pool's
|
||||
// positions loadOrgPools read. A failed read logs and sets
|
||||
// the error in the badge's place, never an empty answer ("A failed read is
|
||||
// never an answer", docs/go-conventions.md). Without a subscription or an
|
||||
// active default pool nothing is read.
|
||||
func (h *OperatorPartialsHandler) fillDisplacement(r *http.Request, summary *BillingSummaryViewModel, orgID string, pools []entitlements.ResourcePool, subs []billing.Subscription) {
|
||||
func (h *OperatorPartialsHandler) fillDisplacement(r *http.Request, summary *BillingSummaryViewModel, orgID string, pools []entitlements.ResourcePool, live defaultLadders, subs []billing.Subscription) {
|
||||
poolID := defaultPoolID(pools)
|
||||
if poolID == "" || len(subs) == 0 {
|
||||
return
|
||||
}
|
||||
title, err := h.accountDisplacementTitle(r, summary.BillingAccountID, poolID, subs)
|
||||
err := live.err
|
||||
title := ""
|
||||
if err == nil {
|
||||
title, err = h.accountDisplacementTitle(r, summary.BillingAccountID, poolID, live.ladders, subs)
|
||||
}
|
||||
if err != nil {
|
||||
h.Logger.Warn("org enrollment: subscription displacement read failed",
|
||||
slog.String("org_id", orgID),
|
||||
@@ -1848,8 +1957,9 @@ func defaultPoolID(pools []entitlements.ResourcePool) string {
|
||||
|
||||
// accountDisplacementTitle reads the account's current items, their
|
||||
// conferral shapes and its subscriptions' displacements, and words the badge
|
||||
// title for the subscriptions whose status can be displaced.
|
||||
func (h *OperatorPartialsHandler) accountDisplacementTitle(r *http.Request, accountID, poolID string, subs []billing.Subscription) (string, error) {
|
||||
// title for the subscriptions whose status can be displaced; live is the
|
||||
// ladders the default pool holds a position on.
|
||||
func (h *OperatorPartialsHandler) accountDisplacementTitle(r *http.Request, accountID, poolID string, live map[string]bool, subs []billing.Subscription) (string, error) {
|
||||
items, err := h.BillingQ.ListCurrentSubscriptionItemsByBillingAccountID(r.Context(), accountID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list current subscription items: %w", err)
|
||||
@@ -1869,7 +1979,7 @@ func (h *OperatorPartialsHandler) accountDisplacementTitle(r *http.Request, acco
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list account subscription displacements: %w", err)
|
||||
}
|
||||
return displacementTitleFor(subs, items, shapes, rows), nil
|
||||
return displacementTitleFor(subs, items, shapes, rows, live), nil
|
||||
}
|
||||
|
||||
// subscriptionRef is the part of a subscription displacedByID reads.
|
||||
@@ -1885,14 +1995,15 @@ type subscriptionRef struct {
|
||||
// status subscriptionMayBeDisplaced admits, over its current products, the
|
||||
// rungs and its displacements. A subscription with no displaced ladder has no
|
||||
// entry. The Billing card and the subscriptions list group by subscription
|
||||
// here. It reads no row.
|
||||
func displacedByID(subs []subscriptionRef, products map[string][]string, rungs map[string][]entitlements.Rung, displacements map[string][]entitlements.Displacement) map[string]displacedSubscription {
|
||||
// here. live is the ladder ids with a live position of each subscription's
|
||||
// organization, by subscription id (displacedOnPage). It reads no row.
|
||||
func displacedByID(subs []subscriptionRef, products map[string][]string, rungs map[string][]entitlements.Rung, displacements map[string][]entitlements.Displacement, live map[string]map[string]bool) map[string]displacedSubscription {
|
||||
out := make(map[string]displacedSubscription)
|
||||
for _, s := range subs {
|
||||
if !subscriptionMayBeDisplaced(s.Status) {
|
||||
continue
|
||||
}
|
||||
if d := displacedOnPage(products[s.ID], rungs, displacements[s.ID], s.PauseBehavior); len(d.Ladders) > 0 {
|
||||
if d := displacedOnPage(products[s.ID], rungs, displacements[s.ID], live[s.ID], s.PauseBehavior); len(d.Ladders) > 0 {
|
||||
out[s.ID] = d
|
||||
}
|
||||
}
|
||||
@@ -1917,13 +2028,16 @@ func productsBySubscription[R itemRow](items []R) map[string][]string {
|
||||
// displacementTitleFor words the badge title from the account's
|
||||
// subscriptions, their current items, those products' conferral shapes and
|
||||
// the account's displacements, for the subscriptions whose status can be
|
||||
// displaced. It reads no row.
|
||||
func displacementTitleFor(subs []billing.Subscription, items []billing.ListCurrentSubscriptionItemsByBillingAccountIDRow, shapes []entitlements.ListConferralShapesRow, rows []entitlements.ListAccountSubscriptionDisplacementsRow) string {
|
||||
// displaced. live is the ladder ids the default pool holds a position on,
|
||||
// which every subscription of the account shares. It reads no row.
|
||||
func displacementTitleFor(subs []billing.Subscription, items []billing.ListCurrentSubscriptionItemsByBillingAccountIDRow, shapes []entitlements.ListConferralShapesRow, rows []entitlements.ListAccountSubscriptionDisplacementsRow, live map[string]bool) string {
|
||||
refs := make([]subscriptionRef, len(subs))
|
||||
liveByID := make(map[string]map[string]bool, len(subs))
|
||||
for i, s := range subs {
|
||||
refs[i] = subscriptionRef{ID: s.SubscriptionID, Status: s.Status, PauseBehavior: s.PauseCollectionBehavior.String}
|
||||
liveByID[s.SubscriptionID] = live
|
||||
}
|
||||
byID := displacedByID(refs, productsBySubscription(items), entitlements.RungsByProduct(shapes), entitlements.DisplacementsBySubscription(rows))
|
||||
byID := displacedByID(refs, productsBySubscription(items), entitlements.RungsByProduct(shapes), entitlements.DisplacementsBySubscription(rows), liveByID)
|
||||
var displaced []displacedSubscription
|
||||
for _, ref := range refs {
|
||||
if d, ok := byID[ref.ID]; ok {
|
||||
@@ -2087,16 +2201,26 @@ func grantsListQuery(p ListParams) string {
|
||||
// (Doc 41: one form over all published products), decorated with the
|
||||
// internal and subscription-supersession markers the composite already
|
||||
// showed. pools is the org's resource pools, already loaded by the caller,
|
||||
// since the supersession check reads them. A load failure comes back as its
|
||||
// own message, the ProductsError contract loadOrgEnrollmentData renders.
|
||||
func (h *OperatorPartialsHandler) loadIssuanceProducts(r *http.Request, pools []entitlements.ResourcePool) ([]IssuanceProductOption, string) {
|
||||
subscriptionHeldLadders := h.subscriptionHeldLadders(r, pools)
|
||||
// since the supersession check reads them. names is the page's resolver; it
|
||||
// names the ladders of a superseded bundle, and is asked only for a product
|
||||
// with such a bundle. A load failure comes back as its own message, the
|
||||
// ProductsError contract loadOrgEnrollmentData renders.
|
||||
func (h *OperatorPartialsHandler) loadIssuanceProducts(r *http.Request, pools []entitlements.ResourcePool, names *operatorNames) ([]IssuanceProductOption, string) {
|
||||
positions := h.subscriptionPositions(r, pools)
|
||||
products, err := h.BillingQ.ListActiveProducts(r.Context())
|
||||
if err != nil {
|
||||
h.Logger.Error("failed to list active products", slog.Any("error", err))
|
||||
return nil, "Failed to load the product catalog. The issuance form is unavailable until this is resolved."
|
||||
}
|
||||
onHeldLadder := h.productsOnLadders(r, subscriptionHeldLadders)
|
||||
tiers := h.tiersOnLadders(r, positions)
|
||||
onHeldLadder := make(map[string]bool, len(tiers))
|
||||
for _, t := range tiers {
|
||||
onHeldLadder[t.ProductID] = true
|
||||
}
|
||||
superseded := supersededLadders(positions, tiers)
|
||||
for _, ids := range superseded {
|
||||
names.WantLadders(ids...)
|
||||
}
|
||||
var out []IssuanceProductOption
|
||||
for _, p := range products {
|
||||
if p.LifecycleStatus != "published" || !p.EntitlementSetID.Valid {
|
||||
@@ -2111,38 +2235,98 @@ func (h *OperatorPartialsHandler) loadIssuanceProducts(r *http.Request, pools []
|
||||
opt.DisplayCategory = p.DisplayCategory.String
|
||||
}
|
||||
opt.SupersedesSubscription = onHeldLadder[p.ProductID]
|
||||
if ids, ok := superseded[p.ProductID]; ok {
|
||||
opt.SupersededLadders = ladderNames(ids, names.Ladder)
|
||||
}
|
||||
out = append(out, opt)
|
||||
}
|
||||
return out, ""
|
||||
}
|
||||
|
||||
// productsOnLadders returns the products that are a tier of any of the given
|
||||
// ladders, in one read. It returns none when no ladder is given or the read
|
||||
// fails, as a failed read of one product's ladders left that product
|
||||
// unflagged.
|
||||
func (h *OperatorPartialsHandler) productsOnLadders(r *http.Request, ladders map[string]bool) map[string]bool {
|
||||
on := map[string]bool{}
|
||||
// tiersOnLadders returns the tier rows of every ladder the positions hold, in
|
||||
// one read. It returns none when no position is given or the read fails, as a
|
||||
// failed read of one product's ladders left that product unflagged.
|
||||
func (h *OperatorPartialsHandler) tiersOnLadders(r *http.Request, positions []entitlements.ListLadderPositionsByPoolRow) []billing.ListTiersWithProductsByLadderIDsRow {
|
||||
ladders := map[string]bool{}
|
||||
for _, pos := range positions {
|
||||
ladders[pos.PlanLadderID] = true
|
||||
}
|
||||
if len(ladders) == 0 {
|
||||
return on
|
||||
return nil
|
||||
}
|
||||
tiers, err := h.BillingQ.ListTiersWithProductsByLadderIDs(r.Context(), slices.Sorted(maps.Keys(ladders)))
|
||||
if err != nil {
|
||||
return on
|
||||
return nil
|
||||
}
|
||||
for _, t := range tiers {
|
||||
on[t.ProductID] = true
|
||||
}
|
||||
return on
|
||||
return tiers
|
||||
}
|
||||
|
||||
// subscriptionHeldLadders returns the set of plan ladders currently occupied by
|
||||
// a subscription-sourced provision, active or suspended, on any of the org's
|
||||
// pools. The issuance form uses it to advise (non-blocking) when a grant would
|
||||
// supersede a subscription-held rung. Pure read; best-effort: a pool whose positions fail to read is
|
||||
// skipped, not surfaced. A suspended position is held: core.confer supersedes
|
||||
// it as it does an active one.
|
||||
func (h *OperatorPartialsHandler) subscriptionHeldLadders(r *http.Request, pools []entitlements.ResourcePool) map[string]bool {
|
||||
held := map[string]bool{}
|
||||
// supersededLadders maps each product to the ladders a grant of it would end
|
||||
// a subscription's tiers on. core.confer supersedes a subscription-sourced
|
||||
// provision that holds a ladder the product is a tier of and ends every rung
|
||||
// the provision holds (docs/models/plan-ladders-transitions.md, invariant 7),
|
||||
// so the ladders are every ladder held by each such provision. A product has
|
||||
// an entry only when one of those provisions holds two or more ladders; the
|
||||
// entry is the ladders' ids, sorted. positions are the subscription-sourced
|
||||
// position rows, tiers the tier rows of the ladders they hold. It reads no
|
||||
// row.
|
||||
func supersededLadders(positions []entitlements.ListLadderPositionsByPoolRow, tiers []billing.ListTiersWithProductsByLadderIDsRow) map[string][]string {
|
||||
held := make(map[string]map[string]bool)
|
||||
onLadder := make(map[string][]string)
|
||||
for _, pos := range positions {
|
||||
if held[pos.ProvisionID] == nil {
|
||||
held[pos.ProvisionID] = make(map[string]bool)
|
||||
}
|
||||
held[pos.ProvisionID][pos.PlanLadderID] = true
|
||||
onLadder[pos.PlanLadderID] = append(onLadder[pos.PlanLadderID], pos.ProvisionID)
|
||||
}
|
||||
provisions := make(map[string]map[string]bool)
|
||||
for _, t := range tiers {
|
||||
for _, id := range onLadder[t.PlanLadderID] {
|
||||
if provisions[t.ProductID] == nil {
|
||||
provisions[t.ProductID] = make(map[string]bool)
|
||||
}
|
||||
provisions[t.ProductID][id] = true
|
||||
}
|
||||
}
|
||||
out := make(map[string][]string)
|
||||
for product, ids := range provisions {
|
||||
ladders := make(map[string]bool)
|
||||
bundle := false
|
||||
for id := range ids {
|
||||
bundle = bundle || len(held[id]) > 1
|
||||
maps.Copy(ladders, held[id])
|
||||
}
|
||||
if bundle {
|
||||
out[product] = slices.Sorted(maps.Keys(ladders))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ladderNames names the ladders by id through name, sorted by name. It answers
|
||||
// nil when any id has no name, so a sentence never names part of a bundle.
|
||||
func ladderNames(ids []string, name func(string) (string, bool)) []string {
|
||||
out := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
n, ok := name(id)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
slices.SortFunc(out, strings.Compare)
|
||||
return out
|
||||
}
|
||||
|
||||
// subscriptionPositions returns the live positions, active or suspended, that
|
||||
// a subscription-sourced provision holds on any of the org's pools. The
|
||||
// issuance form uses them to advise (non-blocking) when a grant would
|
||||
// supersede a subscription-held rung. Pure read; best-effort: a pool whose
|
||||
// positions fail to read is skipped, not surfaced. A suspended position is
|
||||
// held: core.confer supersedes it as it does an active one.
|
||||
func (h *OperatorPartialsHandler) subscriptionPositions(r *http.Request, pools []entitlements.ResourcePool) []entitlements.ListLadderPositionsByPoolRow {
|
||||
var held []entitlements.ListLadderPositionsByPoolRow
|
||||
for _, pool := range pools {
|
||||
positions, err := h.EntitlementsQ.ListLadderPositionsByPool(r.Context(), pool.PoolID)
|
||||
if err != nil {
|
||||
@@ -2150,7 +2334,7 @@ func (h *OperatorPartialsHandler) subscriptionHeldLadders(r *http.Request, pools
|
||||
}
|
||||
for _, pos := range positions {
|
||||
if pos.SubscriptionID.Valid {
|
||||
held[pos.PlanLadderID] = true
|
||||
held = append(held, pos)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,9 +132,13 @@ const issueGrantLead = "A tier product moves the pool to that tier, up or down."
|
||||
// Copy row W). The sentence states what the grant does to the subscription and
|
||||
// names no view, because the operator's billing views only read
|
||||
// (displaced-subscription-waits D4). The grant pauses the subscription's
|
||||
// billing because the console pauses collection at Stripe while a grant holds
|
||||
// every ladder the subscription pays for, so the subscription is not charged
|
||||
// for a period the grant covers.
|
||||
// billing because the console pauses collection at Stripe while grants hold
|
||||
// the ladders that keep every product the subscription pays for from being
|
||||
// delivered (stripe-subscription-management, "A grant's hold pauses the
|
||||
// subscription's collection at Stripe"), so the subscription is not charged
|
||||
// for a period the grant covers. An option whose superseded tier is a tier of
|
||||
// several ladders carries its own line that names them
|
||||
// (issueGrantSupersedeHintOn).
|
||||
const issueGrantSupersedeWarning = "This grant would supersede a tier a subscription pays for; its billing pauses until the grant ends."
|
||||
|
||||
// manageGrantForm is the declaration for Manage (grant-resumption D15):
|
||||
@@ -190,6 +194,20 @@ func issueGrantReasonOptions() []forms.Option {
|
||||
return opts
|
||||
}
|
||||
|
||||
// issueGrantSupersedeHintOn is the hint of a product whose superseded
|
||||
// subscription tier holds several ladders: Copy row G1, naming every ladder,
|
||||
// because core.confer ends the tier on all of them whichever the grant is on
|
||||
// (docs/models/plan-ladders-transitions.md, invariant 7). Billing pauses in
|
||||
// this case: the product the subscription pays for is left alone and a grant
|
||||
// holds the ladder that decides it. It answers "" for no ladders, and the
|
||||
// option then shows issueGrantSupersedeWarning.
|
||||
func issueGrantSupersedeHintOn(ladders []string) string {
|
||||
if len(ladders) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "This grant would supersede a tier a subscription pays for on " + joinWithAnd(ladders) + "; its billing pauses until the grant ends."
|
||||
}
|
||||
|
||||
// issuanceProductOptions builds the Product select's runtime options: a
|
||||
// disabled "Choose a product" placeholder, then every issuable product,
|
||||
// carrying forward the internal and supersession markers the composite
|
||||
@@ -206,7 +224,7 @@ func issuanceProductOptions(products []IssuanceProductOption) []forms.Option {
|
||||
if p.SupersedesSubscription {
|
||||
label += " ⚠"
|
||||
}
|
||||
opts = append(opts, forms.Option{Value: p.ProductID, Label: label, ShowsHint: p.SupersedesSubscription})
|
||||
opts = append(opts, forms.Option{Value: p.ProductID, Label: label, ShowsHint: p.SupersedesSubscription, Hint: issueGrantSupersedeHintOn(p.SupersededLadders)})
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
@@ -145,6 +145,14 @@ func (q failingEnrollmentEntitlementsQ) ListLadderPositionsByPool(ctx context.Co
|
||||
return q.Querier.ListLadderPositionsByPool(ctx, poolID)
|
||||
}
|
||||
|
||||
func (q failingEnrollmentEntitlementsQ) ListLiveLadderIDsBySubscriptionIDs(ctx context.Context, subscriptionIDs []string) ([]entitlements.ListLiveLadderIDsBySubscriptionIDsRow, error) {
|
||||
if q.fail == "ListLiveLadderIDsBySubscriptionIDs" {
|
||||
var none []entitlements.ListLiveLadderIDsBySubscriptionIDsRow
|
||||
return none, errReadFailed
|
||||
}
|
||||
return q.Querier.ListLiveLadderIDsBySubscriptionIDs(ctx, subscriptionIDs)
|
||||
}
|
||||
|
||||
func (q failingEnrollmentEntitlementsQ) ListPoolUsageWithLimitsByPoolID(ctx context.Context, poolID string) ([]entitlements.ListPoolUsageWithLimitsByPoolIDRow, error) {
|
||||
if q.fail == "ListPoolUsageWithLimitsByPoolID" {
|
||||
var none []entitlements.ListPoolUsageWithLimitsByPoolIDRow
|
||||
@@ -399,6 +407,7 @@ func enrollmentReadFailures() []enrollmentReadFailure {
|
||||
{"a failed displacement item read names the badge error", "billing", "ListCurrentSubscriptionItemsByBillingAccountID", wantDisplacementError("ListCurrentSubscriptionItemsByBillingAccountID")},
|
||||
{"a failed displacement shape read names the badge error", "entitlements", "ListConferralShapes", wantDisplacementError("ListConferralShapes")},
|
||||
{"a failed displacement read names the badge error", "entitlements", "ListAccountSubscriptionDisplacements", wantDisplacementError("ListAccountSubscriptionDisplacements")},
|
||||
{"a failed position read names the badge error", "entitlements", "ListLadderPositionsByPool", wantDisplacementError("ListLadderPositionsByPool")},
|
||||
{"a failed invoice read shows no invoice", "billing", "GetLatestInvoiceByBillingAccountID", wantGetLatestInvoiceByBillingAccountID},
|
||||
{"a failed balance read shows a zero balance", "billing", "GetOutstandingBalanceByBillingAccountID", wantGetOutstandingBalanceByBillingAccountID},
|
||||
{"a failed member read names the section error", "organization", "ListActiveMembersByOrgID", wantListActiveMembersByOrgID},
|
||||
|
||||
@@ -524,7 +524,10 @@ func issueGrantPanelHTML(t *testing.T, out string) string {
|
||||
|
||||
// TestOrgEnrollmentIssueGrantSupersedeHint covers the supersession warning
|
||||
// as the Product field's hint (grant-pauses-subscription-billing Copy row
|
||||
// W): present and hidden at rest when a marked product is offered, absent
|
||||
// W), and the option's own hint of a product that supersedes a bundle (Copy
|
||||
// row G1, grant-names-every-emptied-ladder) as that option's data-hint with
|
||||
// Copy W in data-hint-default: present and hidden at rest when a marked
|
||||
// product is offered, absent
|
||||
// when none is, shown in a refused submission that chose a marked product,
|
||||
// hidden in one that chose an unmarked product, and never in the message.
|
||||
func TestOrgEnrollmentIssueGrantSupersedeHint(t *testing.T) {
|
||||
@@ -532,7 +535,10 @@ func TestOrgEnrollmentIssueGrantSupersedeHint(t *testing.T) {
|
||||
marked.IssuanceProducts = []IssuanceProductOption{
|
||||
{ProductID: "prod-plain", Name: "Plain"},
|
||||
{ProductID: "prod-pro", Name: "Pro", SupersedesSubscription: true},
|
||||
{ProductID: "prod-bundle", Name: "Bundle", SupersedesSubscription: true, SupersededLadders: []string{"Duo", "Wiki"}},
|
||||
{ProductID: "prod-single", Name: "Single", SupersedesSubscription: true},
|
||||
}
|
||||
const bundleCopy = "This grant would supersede a tier a subscription pays for on Duo and Wiki; its billing pauses until the grant ends."
|
||||
render := func(d OrgEnrollmentData, mode forms.Mode, values forms.Values) string {
|
||||
d.IssueGrantForm = issueGrantFormView(d.OrgID, d.IssuanceProducts, mode, values, nil, "")
|
||||
return issueGrantPanelHTML(t, renderOrgEnrollment(t, d))
|
||||
@@ -581,6 +587,21 @@ func TestOrgEnrollmentIssueGrantSupersedeHint(t *testing.T) {
|
||||
if msg := at.All(`.app-form-message`); len(msg) != 1 || strings.TrimSpace(msg[0].Text()) != "A tier product moves the pool to that tier, up or down." || at.Count(`.app-form-message [role=alert]`) != 0 {
|
||||
t.Errorf("at rest, the message is not the lead alone:\n%s", atHTML)
|
||||
}
|
||||
if got, _ := at.One(t, `option[value=prod-bundle]`).Attr("data-hint"); got != bundleCopy {
|
||||
t.Errorf("the option of a product that supersedes a bundle carries data-hint %q, want %q", got, bundleCopy)
|
||||
}
|
||||
for _, id := range []string{"prod-pro", "prod-single"} {
|
||||
if got, ok := at.One(t, `option[value=`+id+`]`).Attr("data-hint"); ok {
|
||||
t.Errorf("the option of a product that supersedes a one-ladder tier carries data-hint %q, want none", got)
|
||||
}
|
||||
}
|
||||
if got, _ := at.One(t, `[id="`+supersedeHintID+`"]`).Attr("data-hint-default"); got != supersedeWarningCopy {
|
||||
t.Errorf("the Product hint carries data-hint-default %q, want Copy W", got)
|
||||
}
|
||||
bundleHTML := render(marked, forms.ModeSubmission, chose("prod-bundle"))
|
||||
if got := strings.TrimSpace(htmltest.Parse(t, bundleHTML).One(t, `[id="`+supersedeHintID+`"]`).Text()); got != bundleCopy {
|
||||
t.Errorf("a refused submission that chose a bundle's product renders the hint %q, want %q", got, bundleCopy)
|
||||
}
|
||||
markedHTML := render(marked, forms.ModeSubmission, chose("prod-pro"))
|
||||
if miss := hintMiss(htmltest.Parse(t, markedHTML), false); miss != "" {
|
||||
t.Errorf("in a refused submission that chose a marked product, the Product hint %s:\n%s", miss, markedHTML)
|
||||
@@ -1396,6 +1417,12 @@ func pausedOn(holder displacedHolder, ladders ...displacedLadder) displacedSubsc
|
||||
return d
|
||||
}
|
||||
|
||||
// withEmptied is d with the ladders its subscription delivers nothing on.
|
||||
func withEmptied(d displacedSubscription, emptied ...displacedLadder) displacedSubscription {
|
||||
d.Emptied = emptied
|
||||
return d
|
||||
}
|
||||
|
||||
type displacementTitleRow struct {
|
||||
name string
|
||||
subscriptions int
|
||||
@@ -1413,6 +1440,8 @@ func displacementTitleRows() []displacementTitleRow {
|
||||
other := displacedHolder{ProvisionID: "p-other", Source: "grant"}
|
||||
sub := displacedHolder{ProvisionID: "p-sub", Source: "subscription"}
|
||||
purchase := displacedHolder{ProvisionID: "p-purchase", Source: "purchase"}
|
||||
duo := displacedLadder{ID: "l-duo", Name: "Duo"}
|
||||
docsEmptied := displacedLadder{ID: "l-docs", Name: "Docs"}
|
||||
return []displacementTitleRow{
|
||||
{"a grant holds the ladder", 1, []displacedSubscription{displacedOn(grant, hosting)}, "Billing continues while a grant holds Hosting."},
|
||||
{"another subscription holds the ladder", 1, []displacedSubscription{displacedOn(sub, hosting)}, "Billing continues while another subscription holds Hosting."},
|
||||
@@ -1452,10 +1481,44 @@ func displacementTitleRows() []displacementTitleRow {
|
||||
{"three subscriptions, two paused under two grants", 3, []displacedSubscription{
|
||||
pausedOn(grant, hosting), pausedOn(other, wiki),
|
||||
}, "Billing paused on 2 of 3 subscriptions while grants hold Hosting and Wiki."},
|
||||
{"one subscription, paused, with an emptied ladder", 1, []displacedSubscription{withEmptied(pausedOn(grant, wiki), duo)},
|
||||
"Billing paused while a grant holds Wiki; nothing is delivered on Duo."},
|
||||
{"one subscription, continuing, with an emptied ladder", 1, []displacedSubscription{withEmptied(displacedOn(grant, wiki), duo)},
|
||||
"Billing continues while a grant holds Wiki; nothing is delivered on Duo."},
|
||||
{"two of three subscriptions paused, with an emptied ladder", 3, []displacedSubscription{
|
||||
withEmptied(pausedOn(grant, wiki), duo), withEmptied(pausedOn(grant, wiki), duo),
|
||||
}, "Billing paused on 2 of 3 subscriptions while a grant holds Wiki; nothing is delivered on Duo."},
|
||||
{"one of two continuing, with an emptied ladder", 2, []displacedSubscription{withEmptied(displacedOn(grant, wiki), duo)},
|
||||
"Billing continues on 1 of 2 subscriptions while a grant holds Wiki; nothing is delivered on Duo."},
|
||||
{"paused and continuing, the paused one with an emptied ladder", 3, []displacedSubscription{
|
||||
withEmptied(pausedOn(grant, wiki), duo), displacedOn(sub, hosting),
|
||||
}, "Billing paused on 1 of 3 subscriptions while a grant holds Wiki; nothing is delivered on Duo. Billing continues on 1 while another subscription holds Hosting."},
|
||||
{"paused and continuing, the continuing one with an emptied ladder", 3, []displacedSubscription{
|
||||
pausedOn(grant, hosting), withEmptied(displacedOn(sub, wiki), duo),
|
||||
}, "Billing paused on 1 of 3 subscriptions while a grant holds Hosting. Billing continues on 1 while another subscription holds Wiki; nothing is delivered on Duo."},
|
||||
{"paused under two grants, with an emptied ladder", 1, []displacedSubscription{withEmptied(displacedSubscription{
|
||||
Ladders: []displacedLadder{hosting, wiki},
|
||||
Holders: []displacedHolder{grant, other},
|
||||
Paused: true,
|
||||
}, duo)}, "Billing paused while grants hold Hosting and Wiki; nothing is delivered on Duo."},
|
||||
{"other plans hold, with an emptied ladder", 1, []displacedSubscription{withEmptied(displacedSubscription{
|
||||
Ladders: []displacedLadder{hosting, wiki},
|
||||
Holders: []displacedHolder{grant, other},
|
||||
}, duo)}, "Billing continues while other plans hold Hosting and Wiki; nothing is delivered on Duo."},
|
||||
{"a ladder emptied for one subscription and held for another", 3, []displacedSubscription{
|
||||
pausedOn(grant, duo), withEmptied(pausedOn(grant, wiki), duo),
|
||||
}, "Billing paused on 2 of 3 subscriptions while a grant holds Duo and Wiki."},
|
||||
{"two emptied ladders, out of name order", 1, []displacedSubscription{withEmptied(pausedOn(grant, wiki), duo, docsEmptied)},
|
||||
"Billing paused while a grant holds Wiki; nothing is delivered on Docs and Duo."},
|
||||
{"paused, held by a grant on one ladder and a subscription on the other", 1, []displacedSubscription{{
|
||||
Ladders: []displacedLadder{hosting, wiki},
|
||||
Holders: []displacedHolder{grant, sub},
|
||||
Paused: true,
|
||||
}}, "Billing paused while other plans hold Hosting and Wiki."},
|
||||
{"paused but held by a purchase", 1, []displacedSubscription{displacedOnPage([]string{"tier"},
|
||||
map[string][]entitlements.Rung{"tier": {{LadderID: "l-hosting", Rank: 1}}},
|
||||
[]entitlements.Displacement{{LadderID: "l-hosting", LadderName: "Hosting", HolderProvisionID: "p-purchase", HolderSource: "purchase"}},
|
||||
"void")}, "Billing continues while another plan holds Hosting."},
|
||||
nil, "void")}, "Billing continues while another plan holds Hosting."},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1478,9 +1541,19 @@ type displacedOnPageRow struct {
|
||||
products []string
|
||||
rungs map[string][]entitlements.Rung
|
||||
displacements []entitlements.Displacement
|
||||
live map[string]bool
|
||||
want displacedSubscription
|
||||
}
|
||||
|
||||
// bundleRungs is a product that is a tier of two ladders, Hosting then
|
||||
// Support.
|
||||
func bundleRungs() map[string][]entitlements.Rung {
|
||||
return map[string][]entitlements.Rung{"bundle": {
|
||||
{LadderID: "l-hosting", LadderName: "Hosting", Rank: 1},
|
||||
{LadderID: "l-support", LadderName: "Support", Rank: 1},
|
||||
}}
|
||||
}
|
||||
|
||||
func displacedOnPageRows() []displacedOnPageRow {
|
||||
held := func(ladder, name string) entitlements.Displacement {
|
||||
return entitlements.Displacement{LadderID: ladder, LadderName: name, HolderProvisionID: "p-grant", HolderSource: "grant"}
|
||||
@@ -1490,36 +1563,49 @@ func displacedOnPageRows() []displacedOnPageRow {
|
||||
return []displacedOnPageRow{
|
||||
{"a displacement on a ladder a current product names", "",
|
||||
[]string{"tier"}, map[string][]entitlements.Rung{"tier": {{LadderID: "l-hosting", Rank: 1}}},
|
||||
[]entitlements.Displacement{held("l-hosting", "Hosting")},
|
||||
[]entitlements.Displacement{held("l-hosting", "Hosting")}, nil,
|
||||
displacedSubscription{Ladders: []displacedLadder{hosting}, Holders: []displacedHolder{grant}}},
|
||||
{"the item moved off the ladder", "",
|
||||
[]string{"other"}, map[string][]entitlements.Rung{"other": {{LadderID: "l-support", Rank: 1}}},
|
||||
[]entitlements.Displacement{held("l-hosting", "Hosting")},
|
||||
[]entitlements.Displacement{held("l-hosting", "Hosting")}, nil,
|
||||
displacedSubscription{}},
|
||||
{"the item removed", "",
|
||||
nil, map[string][]entitlements.Rung{"tier": {{LadderID: "l-hosting", Rank: 1}}},
|
||||
[]entitlements.Displacement{held("l-hosting", "Hosting")},
|
||||
[]entitlements.Displacement{held("l-hosting", "Hosting")}, nil,
|
||||
displacedSubscription{}},
|
||||
{"a bundle displaced on one ladder", "",
|
||||
[]string{"bundle"}, map[string][]entitlements.Rung{"bundle": {{LadderID: "l-hosting", Rank: 1}, {LadderID: "l-support", Rank: 1}}},
|
||||
[]entitlements.Displacement{held("l-support", "Support")},
|
||||
[]string{"bundle"}, bundleRungs(),
|
||||
[]entitlements.Displacement{held("l-support", "Support")}, map[string]bool{"l-support": true},
|
||||
displacedSubscription{Ladders: []displacedLadder{{ID: "l-support", Name: "Support"}}, Holders: []displacedHolder{grant}, Emptied: []displacedLadder{{ID: "l-hosting", Name: "Hosting"}}}},
|
||||
{"a bundle displaced on both ladders by one grant", "",
|
||||
[]string{"bundle"}, bundleRungs(),
|
||||
[]entitlements.Displacement{held("l-hosting", "Hosting"), held("l-support", "Support")}, map[string]bool{"l-hosting": true, "l-support": true},
|
||||
displacedSubscription{Ladders: []displacedLadder{hosting, {ID: "l-support", Name: "Support"}}, Holders: []displacedHolder{grant, grant}}},
|
||||
{"a bundle displaced on one ladder, the other held", "",
|
||||
[]string{"bundle"}, bundleRungs(),
|
||||
[]entitlements.Displacement{held("l-support", "Support")}, map[string]bool{"l-hosting": true, "l-support": true},
|
||||
displacedSubscription{Ladders: []displacedLadder{{ID: "l-support", Name: "Support"}}, Holders: []displacedHolder{grant}}},
|
||||
{"a bundle held by a grant on one ladder and a subscription on the other", "void",
|
||||
[]string{"bundle"}, bundleRungs(),
|
||||
[]entitlements.Displacement{held("l-hosting", "Hosting"), {LadderID: "l-support", LadderName: "Support", HolderProvisionID: "p-sub", HolderSource: "subscription"}},
|
||||
map[string]bool{"l-hosting": true, "l-support": true},
|
||||
displacedSubscription{Ladders: []displacedLadder{hosting, {ID: "l-support", Name: "Support"}}, Holders: []displacedHolder{grant, {ProvisionID: "p-sub", Source: "subscription"}}, Paused: true}},
|
||||
{"a recorded pause under grants", "void",
|
||||
[]string{"tier"}, map[string][]entitlements.Rung{"tier": {{LadderID: "l-hosting", Rank: 1}}},
|
||||
[]entitlements.Displacement{held("l-hosting", "Hosting")},
|
||||
[]entitlements.Displacement{held("l-hosting", "Hosting")}, nil,
|
||||
displacedSubscription{Ladders: []displacedLadder{hosting}, Holders: []displacedHolder{grant}, Paused: true}},
|
||||
{"a recorded foreign pause under grants", "mark_uncollectible",
|
||||
[]string{"tier"}, map[string][]entitlements.Rung{"tier": {{LadderID: "l-hosting", Rank: 1}}},
|
||||
[]entitlements.Displacement{held("l-hosting", "Hosting")},
|
||||
[]entitlements.Displacement{held("l-hosting", "Hosting")}, nil,
|
||||
displacedSubscription{Ladders: []displacedLadder{hosting}, Holders: []displacedHolder{grant}, Paused: true}},
|
||||
{"a recorded pause under a subscription", "void",
|
||||
[]string{"tier"}, map[string][]entitlements.Rung{"tier": {{LadderID: "l-hosting", Rank: 1}}},
|
||||
[]entitlements.Displacement{{LadderID: "l-hosting", LadderName: "Hosting", HolderProvisionID: "p-sub", HolderSource: "subscription"}},
|
||||
[]entitlements.Displacement{{LadderID: "l-hosting", LadderName: "Hosting", HolderProvisionID: "p-sub", HolderSource: "subscription"}}, nil,
|
||||
displacedSubscription{Ladders: []displacedLadder{hosting}, Holders: []displacedHolder{{ProvisionID: "p-sub", Source: "subscription"}}}},
|
||||
{"a subscription switched above the grant", "",
|
||||
[]string{"tier"}, map[string][]entitlements.Rung{"tier": {{LadderID: "l-hosting", Rank: 3}}},
|
||||
[]entitlements.Displacement{{LadderID: "l-hosting", LadderName: "Hosting", DisplacedProductID: "old", HolderProvisionID: "p-grant",
|
||||
HolderSource: "grant", HolderProductID: "granted", HolderRank: 2, HolderRanked: true}},
|
||||
HolderSource: "grant", HolderProductID: "granted", HolderRank: 2, HolderRanked: true}}, nil,
|
||||
displacedSubscription{}},
|
||||
}
|
||||
}
|
||||
@@ -1530,7 +1616,7 @@ func displacedOnPageRows() []displacedOnPageRow {
|
||||
func TestDisplacedOnPage(t *testing.T) {
|
||||
for _, tc := range displacedOnPageRows() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := displacedOnPage(tc.products, tc.rungs, tc.displacements, tc.behavior); !reflect.DeepEqual(got, tc.want) {
|
||||
if got := displacedOnPage(tc.products, tc.rungs, tc.displacements, tc.live, tc.behavior); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("displacedOnPage(%s) = %+v, want %+v", tc.name, got, tc.want)
|
||||
}
|
||||
})
|
||||
@@ -1605,7 +1691,7 @@ func TestDisplacementTitleFor(t *testing.T) {
|
||||
for _, s := range tc.subs {
|
||||
items = append(items, billing.ListCurrentSubscriptionItemsByBillingAccountIDRow{SubscriptionID: s.SubscriptionID, ProductID: "tier"})
|
||||
}
|
||||
if got := displacementTitleFor(tc.subs, items, shapes, tc.rows); got != tc.want {
|
||||
if got := displacementTitleFor(tc.subs, items, shapes, tc.rows, nil); got != tc.want {
|
||||
t.Errorf("displacementTitleFor(%s) = %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
})
|
||||
@@ -1689,7 +1775,7 @@ func TestSubscriptionTitlesFor(t *testing.T) {
|
||||
items = append(items, billing.ListCurrentSubscriptionItemsBySubscriptionIDsRow{SubscriptionID: s.SubscriptionID, ProductID: "tier-Hosting"})
|
||||
}
|
||||
}
|
||||
if got := subscriptionTitlesFor(tc.subs, items, shapes, tc.rows); !maps.Equal(got, tc.want) {
|
||||
if got := subscriptionTitlesFor(tc.subs, items, shapes, tc.rows, nil); !maps.Equal(got, tc.want) {
|
||||
t.Errorf("subscriptionTitlesFor(%s) = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package server
|
||||
|
||||
// The organization page for a subscription to a product that is a tier of two
|
||||
// ladders (grant-names-every-emptied-ladder D2 and D3), characterized as
|
||||
// TestOrgEnrollmentGolden characterizes its states: the data
|
||||
// loadOrgEnrollmentData returns and the operator_enrollment.html render of
|
||||
// it, compared byte for byte with the files under testdata/org_enrollment. The
|
||||
// test builds its own World, so the Issue grant catalog the files keep is its
|
||||
// own two products and no state of the other golden gains them; -update
|
||||
// rewrites the files.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/golden"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world"
|
||||
)
|
||||
|
||||
// newTwoLadderFixture is two organizations that each pay for Both, a tier of
|
||||
// the ladders Near and Far: the first with no grant, the second with its
|
||||
// collection paused and a grant of Higher, a tier of Near alone, over the
|
||||
// subscription.
|
||||
func newTwoLadderFixture(t *testing.T) *enrollmentFixture {
|
||||
t.Helper()
|
||||
f := &enrollmentFixture{enrollmentBuilder: newEnrollmentBuilder(t, world.New(t, dbtest.Open(t)))}
|
||||
w := f.w
|
||||
both, higher := f.priced("Both", 1000), f.priced("Higher", 2000)
|
||||
f.ladder("Near", both, higher)
|
||||
f.ladder("Far", both)
|
||||
plain := f.orgType("plain", nil)
|
||||
|
||||
o := f.org("two-ladder-subscription", plain)
|
||||
sub := w.Subscribe(o, world.SubOpts{PeriodEnd: noon(2099, time.January, 15)}, both)
|
||||
f.norm.Name("subscription", "two-ladder", sub.ID)
|
||||
f.states = append(f.states, enrollmentState{name: "two-ladder-subscription", orgID: o.ID})
|
||||
|
||||
o = f.org("grant-over-two-ladder-subscription-paused", plain)
|
||||
sub = w.Subscribe(o, world.SubOpts{PeriodEnd: noon(2099, time.January, 15)}, both)
|
||||
f.norm.Name("subscription", "grant-over-two-ladder", sub.ID)
|
||||
w.PauseCollection(sub)
|
||||
f.grant("Higher", w.Issue(o, higher, world.IssueOpts{}))
|
||||
f.states = append(f.states, enrollmentState{name: "grant-over-two-ladder-subscription-paused", orgID: o.ID})
|
||||
return f
|
||||
}
|
||||
|
||||
func TestOrgEnrollmentTwoLadderGolden(t *testing.T) {
|
||||
f := newTwoLadderFixture(t)
|
||||
h := catalogReadsHandler(t, catalogReadsConfig(f.w))
|
||||
set := OperatorSet(t)
|
||||
|
||||
for _, s := range f.states {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
data := f.cut(h.loadOrgEnrollmentData(f.request(s), s.orgID, ""))
|
||||
base := "testdata/org_enrollment/" + s.name
|
||||
golden.Compare(t, base+".json", f.normalize(enrollmentDataJSON(t, data)))
|
||||
golden.Compare(t, base+".html", f.normalize(Render(t, set, "operator_enrollment.html", data)))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
||||
)
|
||||
|
||||
type supersededLaddersRow struct {
|
||||
name string
|
||||
positions []entitlements.ListLadderPositionsByPoolRow
|
||||
tiers []billing.ListTiersWithProductsByLadderIDsRow
|
||||
want map[string][]string
|
||||
}
|
||||
|
||||
func heldOn(provision, ladder, status string) entitlements.ListLadderPositionsByPoolRow {
|
||||
return entitlements.ListLadderPositionsByPoolRow{ProvisionID: provision, PlanLadderID: ladder, PositionStatus: status}
|
||||
}
|
||||
|
||||
// tierOf is the tier rows of the product P on the ladders.
|
||||
func tierOf(ladders ...string) []billing.ListTiersWithProductsByLadderIDsRow {
|
||||
var rows []billing.ListTiersWithProductsByLadderIDsRow
|
||||
for _, l := range ladders {
|
||||
rows = append(rows, billing.ListTiersWithProductsByLadderIDsRow{PlanLadderID: l, ProductID: "P"})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func supersededLaddersRows() []supersededLaddersRow {
|
||||
return []supersededLaddersRow{
|
||||
{"a one-ladder subscription",
|
||||
[]entitlements.ListLadderPositionsByPoolRow{heldOn("s1", "L1", "active")}, tierOf("L1"), map[string][]string{}},
|
||||
{"a bundle subscription, a product of its first ladder",
|
||||
[]entitlements.ListLadderPositionsByPoolRow{heldOn("b", "L2", "active"), heldOn("b", "L1", "active")}, tierOf("L1"),
|
||||
map[string][]string{"P": {"L1", "L2"}}},
|
||||
{"a bundle subscription, a product of its other ladder",
|
||||
[]entitlements.ListLadderPositionsByPoolRow{heldOn("b", "L2", "active"), heldOn("b", "L1", "active")}, tierOf("L2"),
|
||||
map[string][]string{"P": {"L1", "L2"}}},
|
||||
{"a product of no held ladder",
|
||||
[]entitlements.ListLadderPositionsByPoolRow{heldOn("b", "L2", "active"), heldOn("b", "L1", "active")}, tierOf("L9"), map[string][]string{}},
|
||||
{"a one-ladder subscription and a bundle on the product's ladders",
|
||||
[]entitlements.ListLadderPositionsByPoolRow{heldOn("s1", "L1", "active"), heldOn("b", "L3", "active"), heldOn("b", "L2", "active")},
|
||||
tierOf("L1", "L2"), map[string][]string{"P": {"L1", "L2", "L3"}}},
|
||||
{"two one-ladder subscriptions on the product's two ladders",
|
||||
[]entitlements.ListLadderPositionsByPoolRow{heldOn("s1", "L1", "active"), heldOn("s2", "L2", "active")},
|
||||
tierOf("L1", "L2"), map[string][]string{}},
|
||||
{"a suspended bundle position",
|
||||
[]entitlements.ListLadderPositionsByPoolRow{heldOn("b", "L2", "suspended"), heldOn("b", "L1", "suspended")}, tierOf("L1"),
|
||||
map[string][]string{"P": {"L1", "L2"}}},
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupersededLadders pins which ladders a grant of a product would end a
|
||||
// subscription's tiers on, and that a product whose superseded tiers each hold
|
||||
// one ladder has none (grant-names-every-emptied-ladder D3). The bundles'
|
||||
// positions come in descending ladder order and the function runs many times,
|
||||
// since the ids pass through a map and must come back sorted.
|
||||
func TestSupersededLadders(t *testing.T) {
|
||||
for _, tc := range supersededLaddersRows() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for range 20 {
|
||||
got := supersededLadders(tc.positions, tc.tiers)
|
||||
if !reflect.DeepEqual(got["P"], tc.want["P"]) {
|
||||
t.Fatalf("supersededLadders(%s)[%s] = %v, want %v", tc.name, "P", got["P"], tc.want["P"])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type ladderNamesRow struct {
|
||||
name string
|
||||
ids []string
|
||||
names map[string]string
|
||||
want []string
|
||||
}
|
||||
|
||||
// TestLadderNamesNeedsEveryName pins that the names come back sorted by name,
|
||||
// and that a missing name answers none rather than part of the bundle.
|
||||
func TestLadderNamesNeedsEveryName(t *testing.T) {
|
||||
for _, tc := range []ladderNamesRow{
|
||||
{"every name read", []string{"a", "b"}, map[string]string{"a": "Wiki", "b": "Duo"}, []string{"Duo", "Wiki"}},
|
||||
{"one name missing", []string{"a", "b"}, map[string]string{"a": "Wiki"}, nil},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := ladderNames(tc.ids, func(id string) (string, bool) {
|
||||
n, ok := tc.names[id]
|
||||
return n, ok
|
||||
})
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("ladderNames(%s) = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world"
|
||||
)
|
||||
|
||||
// TestSubscriptionsListNamesEveryLadderOfABundle drives a subscription to a
|
||||
// product of two ladders under a grant on one of them through the real
|
||||
// statements (grant-names-every-emptied-ladder D2): the list's row title, which
|
||||
// reads the live ladder statement and the conferral shapes' ladder names, names
|
||||
// the ladder the grant holds and the one it emptied, and the organization
|
||||
// page's card reads the same words. A grant on the emptied ladder removes it
|
||||
// from the title.
|
||||
func TestSubscriptionsListNamesEveryLadderOfABundle(t *testing.T) {
|
||||
w := world.New(t, dbtest.Open(t))
|
||||
org := w.Org("Bundled", w.OrgType("bundled"), w.Person("Owner"))
|
||||
both := w.Product("Both")
|
||||
w.Price(both, 1000)
|
||||
higher := w.Product("Higher")
|
||||
w.Price(higher, 2000)
|
||||
farTier := w.Product("Far tier")
|
||||
w.Price(farTier, 3000)
|
||||
near := w.Ladder("Near", both, higher)
|
||||
far := w.Ladder("Far", both, farTier)
|
||||
w.BillingAccount(org, "Account")
|
||||
sub := w.Subscribe(org, world.SubOpts{}, both)
|
||||
w.MapSubscription(sub)
|
||||
w.Issue(org, higher, world.IssueOpts{})
|
||||
w.PauseCollection(sub)
|
||||
|
||||
f := displacedListFixture{w: w, orgA: org, subA: sub}
|
||||
title := func(t *testing.T) (listed, card string) {
|
||||
t.Helper()
|
||||
data, _ := f.list(t, catalogReadsConfig(w))
|
||||
listed, _ = titleOf(data, sub.ID)
|
||||
request := httptest.NewRequest(http.MethodGet, "/operator/organizations/"+org.ID, nil)
|
||||
card = catalogReadsHandler(t, catalogReadsConfig(w)).loadOrgEnrollmentData(request, org.ID, "").BillingSummary.DisplacementTitle
|
||||
return listed, card
|
||||
}
|
||||
|
||||
want := "Billing paused while a grant holds " + near.Name + "; nothing is delivered on " + far.Name + "."
|
||||
listed, card := title(t)
|
||||
if listed != want {
|
||||
t.Errorf("the bundle's row title = %q, want %q", listed, want)
|
||||
}
|
||||
if listed != card {
|
||||
t.Errorf("the bundle's list title %q differs from the organization page's %q", listed, card)
|
||||
}
|
||||
|
||||
w.Issue(org, farTier, world.IssueOpts{})
|
||||
want = "Billing paused while a grant holds " + near.Name + "."
|
||||
if after, _ := title(t); after != want {
|
||||
t.Errorf("the title after Far is granted = %q, want %q", after, want)
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,7 @@ func subscriptionsListReadFailures() []subscriptionsListReadFailure {
|
||||
{"a failed item read", "billing", "ListCurrentSubscriptionItemsBySubscriptionIDs"},
|
||||
{"a failed shape read", "entitlements", "ListConferralShapes"},
|
||||
{"a failed displacement read", "entitlements", "ListSubscriptionDisplacementsBySubscriptionIDs"},
|
||||
{"a failed live ladder read", "entitlements", "ListLiveLadderIDsBySubscriptionIDs"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,10 @@ type positionLoader struct {
|
||||
// payingRows is the row of ListSubscriptionRungsByOrg behind each entry of
|
||||
// paying, for the paid plan's card, read with it and nil until then.
|
||||
payingRows map[string]billing.ListSubscriptionRungsByOrgRow
|
||||
// subscriptionRows are all the rows ListSubscriptionRungsByOrg returned,
|
||||
// read with payingRows and nil until then, for the ladders a paying
|
||||
// product is a tier of.
|
||||
subscriptionRows []billing.ListSubscriptionRungsByOrgRow
|
||||
// otherLadders is whether a product is a tier of more than one ladder,
|
||||
// read once per product.
|
||||
otherLadders map[string]bool
|
||||
@@ -94,6 +98,7 @@ func (l *positionLoader) payingOn(ctx context.Context, orgID, ladderID string, h
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read the subscriptions of org %s that pay on a ladder: %w", orgID, err)
|
||||
}
|
||||
l.subscriptionRows = rows
|
||||
l.payingRows = payingRowsByLadder(rows, held)
|
||||
l.paying = payingFacts(l.payingRows, held, rows)
|
||||
}
|
||||
@@ -184,6 +189,35 @@ func (l *positionLoader) isBundleTier(ctx context.Context, productID string) (bo
|
||||
return len(rows) > 1, nil
|
||||
}
|
||||
|
||||
// grantHolds reports whether a grant holds the position: grant_id is set while
|
||||
// it is not default-sourced, because a default position is a grant row too.
|
||||
func grantHolds(pos entitlements.ListLadderPositionsByPoolRow) bool {
|
||||
return pos.GrantID.Valid && !pos.DefaultSourced
|
||||
}
|
||||
|
||||
// grantHeldLadder is the first of the ladders, in their order, other than the
|
||||
// row's own, that rows name for the row's subscription and product and whose
|
||||
// held position a grant holds: the ladder's name and the name of the product
|
||||
// the grant holds there. ok is false when none is. held are the positions by
|
||||
// ladder. It reads no row.
|
||||
func grantHeldLadder(row billing.ListSubscriptionRungsByOrgRow, rows []billing.ListSubscriptionRungsByOrgRow, held map[string]entitlements.ListLadderPositionsByPoolRow, ladders []billing.PlanLadder) (ladder, granted string, ok bool) {
|
||||
named := make(map[string]bool)
|
||||
for _, r := range rows {
|
||||
if r.SubscriptionID == row.SubscriptionID && r.ProductID == row.ProductID {
|
||||
named[r.PlanLadderID] = true
|
||||
}
|
||||
}
|
||||
for _, l := range ladders {
|
||||
if l.PlanLadderID == row.PlanLadderID || !named[l.PlanLadderID] {
|
||||
continue
|
||||
}
|
||||
if pos := held[l.PlanLadderID]; grantHolds(pos) {
|
||||
return l.Name, pos.ProductName, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// payingFrom states the facts of a paying subscription from its row and the
|
||||
// ladder's held position. GrantHolds is the position's grant_id being set
|
||||
// while it is not default-sourced: a default position is a grant row too.
|
||||
@@ -191,7 +225,7 @@ func payingFrom(r billing.ListSubscriptionRungsByOrgRow, held entitlements.ListL
|
||||
return plans.Paying{
|
||||
ProductID: r.ProductID,
|
||||
Rank: r.Rank,
|
||||
GrantHolds: held.GrantID.Valid && !held.DefaultSourced,
|
||||
GrantHolds: grantHolds(held),
|
||||
Suspended: r.Status == "past_due" || r.Status == "unpaid" || r.Status == "paused",
|
||||
Paused: r.Status == "paused",
|
||||
ConsolePaused: r.PauseCollectionBehavior.Valid && r.PauseCollectionMarked,
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": true,
|
||||
"GrantedUntil": "Feb 20, 2099"
|
||||
"GrantedUntil": "Feb 20, 2099",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": true,
|
||||
"GrantedUntil": "Feb 20, 2099"
|
||||
"GrantedUntil": "Feb 20, 2099",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": true,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": true,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
+24
-12
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": true,
|
||||
"GrantedUntil": "Feb 20, 2099"
|
||||
"GrantedUntil": "Feb 20, 2099",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": true,
|
||||
"GrantedUntil": "Feb 20, 2099"
|
||||
"GrantedUntil": "Feb 20, 2099",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
+24
-12
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": true,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": true,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
+24
-12
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
+24
-12
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
+24
-12
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
+7
-1
@@ -169,7 +169,10 @@
|
||||
</div>
|
||||
|
||||
|
||||
<p class="text-muted small mb-2">$10.00/month</p>
|
||||
|
||||
<p class="text-muted small mb-2">$10.00/month, paused</p>
|
||||
|
||||
<p class="small text-danger mb-2">Entitlements are suspended.</p>
|
||||
|
||||
|
||||
<ul class="list-unstyled small mb-3 flex-grow-1">
|
||||
@@ -183,6 +186,9 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+29
-17
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": true,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -225,26 +230,27 @@
|
||||
"MoveLadderID": "",
|
||||
"MoveLabel": "",
|
||||
"DisabledReason": "",
|
||||
"BillingPriceText": "",
|
||||
"BillingRenewsOn": "",
|
||||
"BillingPriceText": "$10.00/month",
|
||||
"BillingRenewsOn": "Jan 15, 2099",
|
||||
"BillingPending": false,
|
||||
"LadderID": "",
|
||||
"MovesTo": "",
|
||||
"MovesOn": "",
|
||||
"EndsOn": "",
|
||||
"Suspension": "",
|
||||
"Suspension": "paused",
|
||||
"CanCancel": false,
|
||||
"CanKeep": false,
|
||||
"CancelBody": "",
|
||||
"Held": false,
|
||||
"PaidUnderGrant": false,
|
||||
"GrantedName": "",
|
||||
"PaidUnderGrant": true,
|
||||
"GrantedName": "Pro",
|
||||
"CollectionPaused": false,
|
||||
"CanResume": false,
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
Vendored
+6
-1
@@ -172,7 +172,9 @@
|
||||
</div>
|
||||
|
||||
|
||||
<p class="text-muted small mb-2">$10.00/month</p>
|
||||
|
||||
<p class="text-muted small mb-2">Paused while Pro is granted on Wiki</p>
|
||||
|
||||
|
||||
|
||||
<ul class="list-unstyled small mb-3 flex-grow-1">
|
||||
@@ -186,6 +188,9 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Vendored
+29
-17
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": true,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -225,8 +230,8 @@
|
||||
"MoveLadderID": "",
|
||||
"MoveLabel": "",
|
||||
"DisabledReason": "",
|
||||
"BillingPriceText": "",
|
||||
"BillingRenewsOn": "",
|
||||
"BillingPriceText": "$10.00/month",
|
||||
"BillingRenewsOn": "Jan 15, 2099",
|
||||
"BillingPending": false,
|
||||
"LadderID": "",
|
||||
"MovesTo": "",
|
||||
@@ -237,14 +242,15 @@
|
||||
"CanKeep": false,
|
||||
"CancelBody": "",
|
||||
"Held": false,
|
||||
"PaidUnderGrant": false,
|
||||
"GrantedName": "",
|
||||
"CollectionPaused": false,
|
||||
"PaidUnderGrant": true,
|
||||
"GrantedName": "Pro",
|
||||
"CollectionPaused": true,
|
||||
"CanResume": false,
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": "Wiki"
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
+6
-1
@@ -184,7 +184,9 @@
|
||||
</div>
|
||||
|
||||
|
||||
<p class="text-muted small mb-2">$10.00/month</p>
|
||||
|
||||
<p class="text-muted small mb-2">Paused while Pro is granted on Wiki, moves to Basic on Jan 15, 2099</p>
|
||||
|
||||
|
||||
|
||||
<ul class="list-unstyled small mb-3 flex-grow-1">
|
||||
@@ -198,6 +200,9 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+31
-19
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": true,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -225,26 +230,27 @@
|
||||
"MoveLadderID": "",
|
||||
"MoveLabel": "",
|
||||
"DisabledReason": "",
|
||||
"BillingPriceText": "",
|
||||
"BillingRenewsOn": "",
|
||||
"BillingPriceText": "$10.00/month",
|
||||
"BillingRenewsOn": "Jan 15, 2099",
|
||||
"BillingPending": false,
|
||||
"LadderID": "",
|
||||
"MovesTo": "",
|
||||
"MovesOn": "",
|
||||
"MovesTo": "Basic",
|
||||
"MovesOn": "Jan 15, 2099",
|
||||
"EndsOn": "",
|
||||
"Suspension": "",
|
||||
"CanCancel": false,
|
||||
"CanKeep": false,
|
||||
"CancelBody": "",
|
||||
"Held": false,
|
||||
"PaidUnderGrant": false,
|
||||
"GrantedName": "",
|
||||
"CollectionPaused": false,
|
||||
"PaidUnderGrant": true,
|
||||
"GrantedName": "Pro",
|
||||
"CollectionPaused": true,
|
||||
"CanResume": false,
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": "Wiki"
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
+6
-1
@@ -182,7 +182,9 @@
|
||||
</div>
|
||||
|
||||
|
||||
<p class="text-muted small mb-2">$10.00/month</p>
|
||||
|
||||
<p class="text-muted small mb-2">Paused while Pro is granted on Wiki, payment due</p>
|
||||
|
||||
|
||||
|
||||
<ul class="list-unstyled small mb-3 flex-grow-1">
|
||||
@@ -196,6 +198,9 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+30
-18
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": true,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -225,26 +230,27 @@
|
||||
"MoveLadderID": "",
|
||||
"MoveLabel": "",
|
||||
"DisabledReason": "",
|
||||
"BillingPriceText": "",
|
||||
"BillingRenewsOn": "",
|
||||
"BillingPriceText": "$10.00/month",
|
||||
"BillingRenewsOn": "Jan 15, 2099",
|
||||
"BillingPending": false,
|
||||
"LadderID": "",
|
||||
"MovesTo": "",
|
||||
"MovesOn": "",
|
||||
"EndsOn": "",
|
||||
"Suspension": "",
|
||||
"Suspension": "payment_due",
|
||||
"CanCancel": false,
|
||||
"CanKeep": false,
|
||||
"CancelBody": "",
|
||||
"Held": false,
|
||||
"PaidUnderGrant": false,
|
||||
"GrantedName": "",
|
||||
"CollectionPaused": false,
|
||||
"PaidUnderGrant": true,
|
||||
"GrantedName": "Pro",
|
||||
"CollectionPaused": true,
|
||||
"CanResume": false,
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": "Wiki"
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
+6
-1
@@ -180,7 +180,9 @@
|
||||
</div>
|
||||
|
||||
|
||||
<p class="text-muted small mb-2">$10.00/month</p>
|
||||
|
||||
<p class="text-muted small mb-2">Billing continues while Pro is granted on Wiki</p>
|
||||
|
||||
|
||||
|
||||
<ul class="list-unstyled small mb-3 flex-grow-1">
|
||||
@@ -194,6 +196,9 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+28
-16
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": true,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -225,8 +230,8 @@
|
||||
"MoveLadderID": "",
|
||||
"MoveLabel": "",
|
||||
"DisabledReason": "",
|
||||
"BillingPriceText": "",
|
||||
"BillingRenewsOn": "",
|
||||
"BillingPriceText": "$10.00/month",
|
||||
"BillingRenewsOn": "Jan 15, 2099",
|
||||
"BillingPending": false,
|
||||
"LadderID": "",
|
||||
"MovesTo": "",
|
||||
@@ -237,14 +242,15 @@
|
||||
"CanKeep": false,
|
||||
"CancelBody": "",
|
||||
"Held": false,
|
||||
"PaidUnderGrant": false,
|
||||
"GrantedName": "",
|
||||
"PaidUnderGrant": true,
|
||||
"GrantedName": "Pro",
|
||||
"CollectionPaused": false,
|
||||
"CanResume": false,
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": "Wiki"
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
+6
-1
@@ -180,7 +180,9 @@
|
||||
</div>
|
||||
|
||||
|
||||
<p class="text-muted small mb-2">$10.00/month</p>
|
||||
|
||||
<p class="text-muted small mb-2">Paused while Pro is granted on Wiki</p>
|
||||
|
||||
|
||||
|
||||
<ul class="list-unstyled small mb-3 flex-grow-1">
|
||||
@@ -194,6 +196,9 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+29
-17
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": true,
|
||||
"GrantedUntil": "Feb 20, 2099"
|
||||
"GrantedUntil": "Feb 20, 2099",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -225,8 +230,8 @@
|
||||
"MoveLadderID": "",
|
||||
"MoveLabel": "",
|
||||
"DisabledReason": "",
|
||||
"BillingPriceText": "",
|
||||
"BillingRenewsOn": "",
|
||||
"BillingPriceText": "$10.00/month",
|
||||
"BillingRenewsOn": "Jan 15, 2099",
|
||||
"BillingPending": false,
|
||||
"LadderID": "",
|
||||
"MovesTo": "",
|
||||
@@ -237,14 +242,15 @@
|
||||
"CanKeep": false,
|
||||
"CancelBody": "",
|
||||
"Held": false,
|
||||
"PaidUnderGrant": false,
|
||||
"GrantedName": "",
|
||||
"CollectionPaused": false,
|
||||
"PaidUnderGrant": true,
|
||||
"GrantedName": "Pro",
|
||||
"CollectionPaused": true,
|
||||
"CanResume": false,
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": "Wiki"
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
@@ -180,7 +180,9 @@
|
||||
</div>
|
||||
|
||||
|
||||
<p class="text-muted small mb-2">$10.00/month</p>
|
||||
|
||||
<p class="text-muted small mb-2">Paused while Pro is granted on Wiki</p>
|
||||
|
||||
|
||||
|
||||
<ul class="list-unstyled small mb-3 flex-grow-1">
|
||||
@@ -194,6 +196,9 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": true,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -225,8 +230,8 @@
|
||||
"MoveLadderID": "",
|
||||
"MoveLabel": "",
|
||||
"DisabledReason": "",
|
||||
"BillingPriceText": "",
|
||||
"BillingRenewsOn": "",
|
||||
"BillingPriceText": "$10.00/month",
|
||||
"BillingRenewsOn": "Jan 15, 2099",
|
||||
"BillingPending": false,
|
||||
"LadderID": "",
|
||||
"MovesTo": "",
|
||||
@@ -237,14 +242,15 @@
|
||||
"CanKeep": false,
|
||||
"CancelBody": "",
|
||||
"Held": false,
|
||||
"PaidUnderGrant": false,
|
||||
"GrantedName": "",
|
||||
"CollectionPaused": false,
|
||||
"PaidUnderGrant": true,
|
||||
"GrantedName": "Pro",
|
||||
"CollectionPaused": true,
|
||||
"CanResume": false,
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": "Wiki"
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
+24
-12
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
+24
-12
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
+24
-12
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
+24
-12
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
+24
-12
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
+24
-12
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
+24
-12
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
+24
-12
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
+8
-4
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -116,7 +118,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": true,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Max>",
|
||||
@@ -154,7 +157,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
|
||||
+24
-12
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": true,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "Resume Standard?",
|
||||
"ResumeBody": "Your subscription replaces the grant. Nothing is charged now; then $20.00 a month.",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
+26
-13
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Gone>",
|
||||
@@ -518,7 +530,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": true,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
|
||||
+24
-12
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Basic>",
|
||||
@@ -78,7 +79,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
@@ -118,7 +120,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Standard>",
|
||||
@@ -156,7 +159,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
@@ -196,7 +200,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -244,7 +249,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Duo Max>",
|
||||
@@ -282,7 +288,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": true,
|
||||
@@ -328,7 +335,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Once>",
|
||||
@@ -366,7 +374,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Unmapped>",
|
||||
@@ -404,7 +413,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Elsewhere>",
|
||||
@@ -442,7 +452,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Bare>",
|
||||
@@ -480,7 +491,8 @@
|
||||
"ResumeTitle": "",
|
||||
"ResumeBody": "",
|
||||
"Granted": false,
|
||||
"GrantedUntil": ""
|
||||
"GrantedUntil": "",
|
||||
"GrantedLadder": ""
|
||||
}
|
||||
],
|
||||
"Enrolled": false,
|
||||
|
||||
+28
-14
@@ -82,98 +82,112 @@
|
||||
"Name": "Basic",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Forum>",
|
||||
"Name": "Forum",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Forum2>",
|
||||
"Name": "Forum2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Large>",
|
||||
"Name": "Large",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Lite>",
|
||||
"Name": "Lite",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Lite2>",
|
||||
"Name": "Lite2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Mid>",
|
||||
"Name": "Mid",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
"Name": "Plus",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
"Name": "Pro",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Small>",
|
||||
"Name": "Small",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Small2>",
|
||||
"Name": "Small2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Starter>",
|
||||
"Name": "Starter",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Starter2>",
|
||||
"Name": "Starter2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Top>",
|
||||
"Name": "Top",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
}
|
||||
],
|
||||
"Members": [],
|
||||
|
||||
+28
-14
@@ -82,98 +82,112 @@
|
||||
"Name": "Basic",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Forum>",
|
||||
"Name": "Forum",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Forum2>",
|
||||
"Name": "Forum2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Large>",
|
||||
"Name": "Large",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Lite>",
|
||||
"Name": "Lite",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Lite2>",
|
||||
"Name": "Lite2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Mid>",
|
||||
"Name": "Mid",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
"Name": "Plus",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
"Name": "Pro",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Small>",
|
||||
"Name": "Small",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Small2>",
|
||||
"Name": "Small2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Starter>",
|
||||
"Name": "Starter",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Starter2>",
|
||||
"Name": "Starter2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Top>",
|
||||
"Name": "Top",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
}
|
||||
],
|
||||
"Members": [],
|
||||
|
||||
@@ -82,98 +82,112 @@
|
||||
"Name": "Basic",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Forum>",
|
||||
"Name": "Forum",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Forum2>",
|
||||
"Name": "Forum2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Large>",
|
||||
"Name": "Large",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Lite>",
|
||||
"Name": "Lite",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Lite2>",
|
||||
"Name": "Lite2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Mid>",
|
||||
"Name": "Mid",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
"Name": "Plus",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
"Name": "Pro",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Small>",
|
||||
"Name": "Small",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Small2>",
|
||||
"Name": "Small2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Starter>",
|
||||
"Name": "Starter",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Starter2>",
|
||||
"Name": "Starter2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Top>",
|
||||
"Name": "Top",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
}
|
||||
],
|
||||
"Members": [],
|
||||
|
||||
+523
@@ -0,0 +1,523 @@
|
||||
|
||||
|
||||
<div class="d-flex align-items-start justify-content-between gap-3 mb-3 app-page-header">
|
||||
<div class="app-page-header-text">
|
||||
<nav aria-label="breadcrumb"><ol class="breadcrumb small mb-1">
|
||||
<li class="breadcrumb-item"><a href="/operator">Operator</a></li><li class="breadcrumb-item"><a href="/operator/organizations">Organizations</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">grant-over-two-ladder-subscription-paused</li>
|
||||
</ol></nav>
|
||||
<h1 class="h2 mb-0">grant-over-two-ladder-subscription-paused</h1>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-3">Owner</dt>
|
||||
<dd class="col-sm-9">
|
||||
|
||||
<a href="/operator/persons/<person:Ann>">Ann</a>
|
||||
|
||||
</dd>
|
||||
|
||||
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 mt-4 mb-2 app-section-header">
|
||||
<h2 class="h5 mb-0">Billing</h2>
|
||||
|
||||
<a href="/operator/billing/accounts?q=grant-over-two-ladder-subscription-paused+" class="btn btn-sm btn-outline-secondary">Full billing</a>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
|
||||
|
||||
<div class="row g-3">
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="text-muted small">Outstanding balance</div>
|
||||
|
||||
<div>0.00</div>
|
||||
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-muted small">Subscriptions</div>
|
||||
|
||||
<div>
|
||||
<span class="badge text-bg-success">Active</span>
|
||||
<span class="badge text-bg-warning" title="Billing paused while a grant holds Near; nothing is delivered on Far.">Not delivering<span class="visually-hidden">, Billing paused while a grant holds Near; nothing is delivered on Far.</span></span>
|
||||
<a href="/operator/billing/subscriptions?q=grant-over-two-ladder-subscription-paused+">1 subscription</a>
|
||||
|
||||
<small class="text-muted">· renews Jan 15, 2099</small>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-muted small">Latest invoice</div>
|
||||
|
||||
<div class="text-muted small">No invoices issued</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 mt-4 mb-2 app-section-header">
|
||||
<h2 class="h5 mb-0">Pools <button type="button" class="btn btn-link p-0 border-0 align-baseline app-help-icon" data-bs-toggle="popover" data-bs-trigger="click" data-bs-title="Pools" data-bs-content="A pool holds what an organization's plan and grants deliver; its sites and services draw from it." aria-label="Help: Pools"><svg class="form-help-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></button></h2>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h3 class="h6 mb-1">
|
||||
Default <span class="badge text-bg-success">Active</span>
|
||||
</h3>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-2">
|
||||
<p class="mb-0"><strong>Higher</strong></p>
|
||||
<p class="mb-0 small text-muted">
|
||||
<span title="since <today> <hh:mm>">Near, rank 1</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 mt-4 mb-2 app-section-header">
|
||||
<h2 class="h5 mb-0">Grants</h2>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div id="plan-grants-panel">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center gap-2">
|
||||
|
||||
<ul class="nav nav-tabs card-header-tabs">
|
||||
|
||||
<li class="nav-item"><a class="nav-link active" href="/operator/organizations/<org:grant-over-two-ladder-subscription-paused>" hx-get="/operator/organizations/<org:grant-over-two-ladder-subscription-paused>" hx-target="#plan-grants-panel" hx-select="#plan-grants-panel" hx-select-oob="#tier-changes-panel,#entitlement-changes-panel" hx-swap="outerHTML" hx-push-url="true">Active</a></li>
|
||||
|
||||
<li class="nav-item"><a class="nav-link " href="/operator/organizations/<org:grant-over-two-ladder-subscription-paused>?tab=history" hx-get="/operator/organizations/<org:grant-over-two-ladder-subscription-paused>?tab=history" hx-target="#plan-grants-panel" hx-select="#plan-grants-panel" hx-select-oob="#tier-changes-panel,#entitlement-changes-panel" hx-swap="outerHTML" hx-push-url="true">History</a></li>
|
||||
|
||||
</ul>
|
||||
|
||||
<div class="flex-shrink-0">
|
||||
|
||||
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-toggle="collapse" data-bs-target="#issueGrantPanel" aria-expanded="false" aria-controls="issueGrantPanel"><svg class="app-opener-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true" focusable="false"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Issue grant</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
|
||||
<div class="collapse" id="issueGrantPanel">
|
||||
|
||||
|
||||
<div class="card app-form-box mb-3"><div class="card-body">
|
||||
<form id="form-operator.enrollment.grant.issue" data-form="operator.enrollment.grant.issue" data-form-kind="create" class="app-form" novalidate hx-post="/partials/operator/organizations/<org:grant-over-two-ladder-subscription-paused>/grant/create" hx-target="#operator-body" hx-swap="innerHTML" hx-disable="find button[type=submit]" hx-indicator="find .htmx-indicator">
|
||||
<div id="form-operator.enrollment.grant.issue-error" class="app-form-error" role="alert"></div>
|
||||
|
||||
<div class="app-form-message mb-2">A tier product moves the pool to that tier, up or down.</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-form-fields">
|
||||
<div id="form-operator.enrollment.grant.issue-product_id" class="mb-3 app-form-field">
|
||||
|
||||
|
||||
<label class="form-label" for="form-operator.enrollment.grant.issue-product_id-control">Product</label>
|
||||
|
||||
<select class="form-select" id="form-operator.enrollment.grant.issue-product_id-control" name="product_id" required aria-describedby="form-operator.enrollment.grant.issue-product_id-warning" hx-get="/partials/operator/products/rule-check" hx-swap="innerHTML" hx-target="#form-operator\.enrollment\.grant\.issue-product_id-warning" hx-trigger="change">
|
||||
<option value="" disabled selected>Choose a product</option>
|
||||
<option value="<product:Both>">Both</option>
|
||||
<option value="<product:Higher>">Higher</option>
|
||||
|
||||
</select>
|
||||
|
||||
<div id="form-operator.enrollment.grant.issue-product_id-warning"></div>
|
||||
|
||||
|
||||
|
||||
|
||||
</div><div id="form-operator.enrollment.grant.issue-quantity" class="mb-3 app-form-field">
|
||||
|
||||
|
||||
<label class="form-label" for="form-operator.enrollment.grant.issue-quantity-control">Quantity <span class="text-muted">(optional)</span></label> <button type="button" class="btn btn-link p-0 border-0 align-baseline app-help-icon" data-bs-toggle="popover" data-bs-trigger="click" data-bs-title="Quantity" data-bs-content="Seats, for per-seat products." aria-label="Help: Quantity"><svg class="form-help-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></button>
|
||||
|
||||
<input type="number" class="form-control" id="form-operator.enrollment.grant.issue-quantity-control" name="quantity" value="" placeholder="1" min="1" max="1000000">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div><div id="form-operator.enrollment.grant.issue-valid_until" class="mb-3 app-form-field">
|
||||
|
||||
|
||||
<label class="form-label" for="form-operator.enrollment.grant.issue-valid_until-control">Valid until <span class="text-muted">(optional)</span></label> <button type="button" class="btn btn-link p-0 border-0 align-baseline app-help-icon" data-bs-toggle="popover" data-bs-trigger="click" data-bs-title="Valid until" data-bs-content="Leave blank for a grant with no end date." aria-label="Help: Valid until"><svg class="form-help-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></button>
|
||||
|
||||
<input type="datetime-local" class="form-control" id="form-operator.enrollment.grant.issue-valid_until-control" name="valid_until" value="" >
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div><div id="form-operator.enrollment.grant.issue-reason" class="mb-3 app-form-field">
|
||||
|
||||
|
||||
<label class="form-label" for="form-operator.enrollment.grant.issue-reason-control">Reason</label>
|
||||
|
||||
<select class="form-select" id="form-operator.enrollment.grant.issue-reason-control" name="reason" required>
|
||||
<option value="" disabled selected>Choose a reason</option>
|
||||
<option value="manual">manual</option>
|
||||
<option value="evaluation">evaluation</option>
|
||||
<option value="promotional">promotional</option>
|
||||
<option value="complimentary">complimentary</option>
|
||||
<option value="sponsored">sponsored</option>
|
||||
<option value="board_decision">board_decision</option>
|
||||
<option value="legacy">legacy</option>
|
||||
|
||||
</select>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div><div id="form-operator.enrollment.grant.issue-description" class="mb-3 app-form-field">
|
||||
|
||||
|
||||
<label class="form-label" for="form-operator.enrollment.grant.issue-description-control">Note <span class="text-muted">(optional)</span></label> <button type="button" class="btn btn-link p-0 border-0 align-baseline app-help-icon" data-bs-toggle="popover" data-bs-trigger="click" data-bs-title="Note" data-bs-content="Why this grant is issued. Shown with the grant in its history." aria-label="Help: Note"><svg class="form-help-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></button>
|
||||
|
||||
<input type="text" class="form-control" id="form-operator.enrollment.grant.issue-description-control" name="description" value="" maxlength="500" autocomplete="off">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div><div id="form-operator.enrollment.grant.issue-resumes_after_supersession" class="mb-3 app-form-field">
|
||||
|
||||
|
||||
|
||||
<label class="form-label" for="form-operator.enrollment.grant.issue-resumes_after_supersession-control">Resumes</label> <button type="button" class="btn btn-link p-0 border-0 align-baseline app-help-icon" data-bs-toggle="popover" data-bs-trigger="click" data-bs-title="Resumes" data-bs-content="If a plan replaces this grant, the grant delivers again when that plan ends." aria-label="Help: Resumes"><svg class="form-help-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></button>
|
||||
|
||||
<div class="app-form-check-control"><input class="form-check-input" type="checkbox" id="form-operator.enrollment.grant.issue-resumes_after_supersession-control" name="resumes_after_supersession" value="true" ></div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 app-form-commit">
|
||||
|
||||
<button type="submit" class="btn btn-primary"><span class="spinner-border spinner-border-sm htmx-indicator me-1" aria-hidden="true"></span>Issue grant</button>
|
||||
|
||||
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="collapse" data-bs-target="#issueGrantPanel">Cancel</button>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div></div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<form id="form-operator.list.controls" data-form="operator.list.controls" data-form-kind="search" class="app-form app-form-bar" method="GET" action="/operator/organizations/<org:grant-over-two-ladder-subscription-paused>" role="search" hx-get="/operator/organizations/<org:grant-over-two-ladder-subscription-paused>" hx-target="#plan-grants-panel" hx-swap="outerHTML" hx-push-url="true" hx-select="#plan-grants-panel" hx-select-oob="#tier-changes-panel,#entitlement-changes-panel">
|
||||
<div id="form-operator.list.controls-error" class="app-form-error" role="alert"></div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<label class="form-label visually-hidden" for="form-operator.list.controls-q-control">Search this list</label>
|
||||
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true"><path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.44.44.98.98l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.98-.98zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0"/></svg></span>
|
||||
<input type="text" class="form-control" id="form-operator.list.controls-q-control" name="q" value="" placeholder="Product or reason" autocomplete="off">
|
||||
|
||||
|
||||
<button type="submit" class="btn btn-outline-secondary"><span class="spinner-border spinner-border-sm htmx-indicator me-1" aria-hidden="true"></span>Search</button>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div id="grants-ledger">
|
||||
|
||||
|
||||
<form id="form-operator.enrollment.grant.manage" data-form="operator.enrollment.grant.manage" data-form-kind="batch" class="app-form" novalidate hx-post="/partials/operator/organizations/<org:grant-over-two-ladder-subscription-paused>/grants/ledger" hx-target="#grants-ledger" hx-swap="outerHTML" hx-disable="find button[type=submit]" hx-indicator="find .htmx-indicator">
|
||||
<div id="form-operator.enrollment.grant.manage-error" class="app-form-error" role="alert"></div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="table-responsive app-scrollbox">
|
||||
<table class="table table-hover table-sm table-record align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th>Reason</th>
|
||||
<th>Issuance <button type="button" class="btn btn-link p-0 border-0 align-baseline app-help-icon" data-bs-toggle="popover" data-bs-trigger="click" data-bs-title="Issuance" data-bs-content="What the record says happened to the grant: active, revoked, or expired." aria-label="Help: Issuance"><svg class="form-help-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></button></th>
|
||||
<th>Delivery <button type="button" class="btn btn-link p-0 border-0 align-baseline app-help-icon" data-bs-toggle="popover" data-bs-trigger="click" data-bs-title="Delivery" data-bs-content="Whether the grant is delivering into the pool now." aria-label="Help: Delivery"><svg class="form-help-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></button></th>
|
||||
<th>Resumes <button type="button" class="btn btn-link p-0 border-0 align-baseline app-help-icon" data-bs-toggle="popover" data-bs-trigger="click" data-bs-title="Resumes" data-bs-content="If a plan replaces this grant, the grant delivers again when that plan ends." aria-label="Help: Resumes"><svg class="form-help-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></button></th>
|
||||
<th>Granted by</th>
|
||||
<th>Qty</th>
|
||||
<th>Activated</th>
|
||||
|
||||
<th>Valid until</th>
|
||||
<th class="text-end"><span class="visually-hidden">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
|
||||
<th scope="row">Higher</th>
|
||||
<td class="text-break"><small class="text-muted">complimentary</small></td>
|
||||
<td><small>Active</small></td>
|
||||
<td><span class="badge text-bg-success">Live</span></td>
|
||||
<td>No</td>
|
||||
<td><a href="/operator/persons/<person:Ann>">Ann</a></td>
|
||||
<td>1</td>
|
||||
<td class="text-nowrap"><small><span class="d-block"><today></span><span class="d-block text-muted"><hh:mm></span></small></td>
|
||||
|
||||
<td class="text-nowrap"><small>—</small></td>
|
||||
|
||||
<td class="text-end">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary ms-1" hx-post="/partials/operator/organizations/<org:grant-over-two-ladder-subscription-paused>/grants/ledger" hx-vals='{"act":"manage","key":"<grant:Higher>"}' hx-include="closest form" hx-target="#grants-ledger" hx-swap="outerHTML" aria-expanded="false"><svg class="app-opener-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true" focusable="false"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Manage</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</form>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mt-2">
|
||||
<p class="small text-body-secondary mb-0">Showing 1–1 of 1</p>
|
||||
|
||||
<p class="small text-body-secondary mb-0">Per page:
|
||||
|
||||
<strong>10</strong>
|
||||
|
||||
<a href="/operator/organizations/<org:grant-over-two-ladder-subscription-paused>?per=25" hx-get="/operator/organizations/<org:grant-over-two-ladder-subscription-paused>?per=25" hx-target="#plan-grants-panel" hx-select="#plan-grants-panel" hx-select-oob="#tier-changes-panel,#entitlement-changes-panel" hx-swap="outerHTML" hx-push-url="true">25</a>
|
||||
|
||||
<a href="/operator/organizations/<org:grant-over-two-ladder-subscription-paused>?per=50" hx-get="/operator/organizations/<org:grant-over-two-ladder-subscription-paused>?per=50" hx-target="#plan-grants-panel" hx-select="#plan-grants-panel" hx-select-oob="#tier-changes-panel,#entitlement-changes-panel" hx-swap="outerHTML" hx-push-url="true">50</a>
|
||||
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 mt-4 mb-2 app-section-header">
|
||||
<h2 class="h5 mb-0">Members</h2>
|
||||
|
||||
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="text-center py-4">
|
||||
<p class="text-muted mb-2">No members.</p>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 mt-4 mb-2 app-section-header">
|
||||
<h2 class="h5 mb-0">Entitlement changes</h2>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div id="entitlement-changes-panel">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="text-center py-4">
|
||||
<p class="text-muted mb-2">No entitlement changes recorded yet.</p>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 mt-4 mb-2 app-section-header">
|
||||
<h2 class="h5 mb-0">Tier changes <button type="button" class="btn btn-link p-0 border-0 align-baseline app-help-icon" data-bs-toggle="popover" data-bs-trigger="click" data-bs-title="Tier changes" data-bs-content="Every tier change on this organization's plan ladders, newest first. Tier names reflect the ladder's current shape." aria-label="Help: Tier changes"><svg class="form-help-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></button></h2>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div id="tier-changes-panel">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm table-record">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ladder</th>
|
||||
<th>Change</th>
|
||||
<th>From</th>
|
||||
<th>To</th>
|
||||
<th>By</th>
|
||||
<th>Reason</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<th scope="row">Near</th>
|
||||
<td><span class="badge text-bg-light border">Upgraded</span></td>
|
||||
<td>Both</td>
|
||||
<td>Higher</td>
|
||||
<td>
|
||||
Ann
|
||||
</td>
|
||||
<td class="text-break" title="test fixture">Complimentary</td>
|
||||
<td class="text-nowrap"><small><span class="d-block"><today></span><span class="d-block text-muted"><hh:mm></span></small></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th scope="row">Far</th>
|
||||
<td><span class="badge text-bg-light border">Started</span></td>
|
||||
<td>—</td>
|
||||
<td>Both</td>
|
||||
<td>
|
||||
<em class="text-muted">webhook</em>
|
||||
</td>
|
||||
<td class="text-break" title="test fixture">test fixture</td>
|
||||
<td class="text-nowrap"><small><span class="d-block"><today></span><span class="d-block text-muted"><hh:mm></span></small></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th scope="row">Near</th>
|
||||
<td><span class="badge text-bg-light border">Started</span></td>
|
||||
<td>—</td>
|
||||
<td>Both</td>
|
||||
<td>
|
||||
<em class="text-muted">webhook</em>
|
||||
</td>
|
||||
<td class="text-break" title="test fixture">test fixture</td>
|
||||
<td class="text-nowrap"><small><span class="d-block"><today></span><span class="d-block text-muted"><hh:mm></span></small></td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mt-2">
|
||||
<p class="small text-body-secondary mb-0">Showing 1–3 of 3</p>
|
||||
|
||||
<p class="small text-body-secondary mb-0">Per page:
|
||||
|
||||
<strong>10</strong>
|
||||
|
||||
<a href="/operator/organizations/<org:grant-over-two-ladder-subscription-paused>?tc_per=25" hx-get="/operator/organizations/<org:grant-over-two-ladder-subscription-paused>?tc_per=25" hx-target="#tier-changes-panel" hx-select="#tier-changes-panel" hx-select-oob="#plan-grants-panel,#entitlement-changes-panel" hx-swap="outerHTML" hx-push-url="true">25</a>
|
||||
|
||||
<a href="/operator/organizations/<org:grant-over-two-ladder-subscription-paused>?tc_per=50" hx-get="/operator/organizations/<org:grant-over-two-ladder-subscription-paused>?tc_per=50" hx-target="#tier-changes-panel" hx-select="#tier-changes-panel" hx-select-oob="#plan-grants-panel,#entitlement-changes-panel" hx-swap="outerHTML" hx-push-url="true">50</a>
|
||||
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
{
|
||||
"ActionError": "",
|
||||
"BillingSummary": {
|
||||
"HasAccount": true,
|
||||
"BillingAccountID": "<uuid>",
|
||||
"AccountStatus": "active",
|
||||
"SubscriptionCount": 1,
|
||||
"SubscriptionState": "active",
|
||||
"SubscriptionNote": "renews Jan 15, 2099",
|
||||
"DisplacementTitle": "Billing paused while a grant holds Near; nothing is delivered on Far.",
|
||||
"DisplacementError": "",
|
||||
"HasInvoice": false,
|
||||
"LatestInvoiceID": "",
|
||||
"LatestInvoiceNumber": "",
|
||||
"LatestInvoiceDate": "",
|
||||
"InvoiceState": "",
|
||||
"OutstandingBalance": 0,
|
||||
"OutstandingBalanceFormatted": "0.00",
|
||||
"Currency": ""
|
||||
},
|
||||
"EntitlementChanges": [],
|
||||
"EntitlementChangesError": "",
|
||||
"EntitlementChangesNav": {
|
||||
"BasePath": "/operator/organizations/<org:grant-over-two-ladder-subscription-paused>",
|
||||
"SearchPlaceholder": "",
|
||||
"FacetParam": "",
|
||||
"FacetOptions": null,
|
||||
"Q": "",
|
||||
"Facet": "",
|
||||
"Page": 1,
|
||||
"Total": 0,
|
||||
"ParamPrefix": "ec_",
|
||||
"Extra": {},
|
||||
"PerPage": 10,
|
||||
"DefaultPerPage": 10,
|
||||
"PerPageOptions": [
|
||||
10,
|
||||
25,
|
||||
50
|
||||
],
|
||||
"Target": "#entitlement-changes-panel",
|
||||
"SyncSelect": "#plan-grants-panel,#tier-changes-panel"
|
||||
},
|
||||
"EntitlementChangesTotal": 0,
|
||||
"Error": "",
|
||||
"GrantsError": "",
|
||||
"GrantsListQuery": "",
|
||||
"GrantsNav": {
|
||||
"BasePath": "/operator/organizations/<org:grant-over-two-ladder-subscription-paused>",
|
||||
"SearchPlaceholder": "Product or reason",
|
||||
"FacetParam": "tab",
|
||||
"FacetOptions": [
|
||||
{
|
||||
"Value": "",
|
||||
"Label": "Active"
|
||||
},
|
||||
{
|
||||
"Value": "history",
|
||||
"Label": "History"
|
||||
}
|
||||
],
|
||||
"Q": "",
|
||||
"Facet": "",
|
||||
"Page": 1,
|
||||
"Total": 1,
|
||||
"ParamPrefix": "",
|
||||
"Extra": {},
|
||||
"PerPage": 10,
|
||||
"DefaultPerPage": 10,
|
||||
"PerPageOptions": [
|
||||
10,
|
||||
25,
|
||||
50
|
||||
],
|
||||
"Target": "#plan-grants-panel",
|
||||
"SyncSelect": "#tier-changes-panel,#entitlement-changes-panel"
|
||||
},
|
||||
"GrantsTotal": 1,
|
||||
"IssuanceProducts": [
|
||||
{
|
||||
"ProductID": "<product:Both>",
|
||||
"Name": "Both",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Higher>",
|
||||
"Name": "Higher",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
}
|
||||
],
|
||||
"Members": [],
|
||||
"MembersCapped": false,
|
||||
"MembersError": "",
|
||||
"MembersTotal": 0,
|
||||
"OrgID": "<org:grant-over-two-ladder-subscription-paused>",
|
||||
"OrgKey": "",
|
||||
"OrgName": "grant-over-two-ladder-subscription-paused",
|
||||
"OrgOwnerName": "Ann",
|
||||
"OrgOwnerPersonID": "<person:Ann>",
|
||||
"PoolMissing": false,
|
||||
"Pools": [
|
||||
{
|
||||
"PoolID": "<pool:grant-over-two-ladder-subscription-paused>",
|
||||
"PoolName": "Default",
|
||||
"PoolType": "default",
|
||||
"Status": "active",
|
||||
"Deliveries": [
|
||||
{
|
||||
"ProvisionID": "<provision:Higher>",
|
||||
"ProductName": "Higher",
|
||||
"ProductID": "<product:Higher>",
|
||||
"Rungs": [
|
||||
{
|
||||
"LadderName": "Near",
|
||||
"Rank": 1,
|
||||
"ActivatedAt": "<today> <hh:mm>"
|
||||
}
|
||||
],
|
||||
"Suspended": false
|
||||
}
|
||||
],
|
||||
"Usage": null,
|
||||
"HasAttachment": true
|
||||
}
|
||||
],
|
||||
"ProductsError": "",
|
||||
"TierChangesNav": {
|
||||
"BasePath": "/operator/organizations/<org:grant-over-two-ladder-subscription-paused>",
|
||||
"SearchPlaceholder": "",
|
||||
"FacetParam": "",
|
||||
"FacetOptions": null,
|
||||
"Q": "",
|
||||
"Facet": "",
|
||||
"Page": 1,
|
||||
"Total": 3,
|
||||
"ParamPrefix": "tc_",
|
||||
"Extra": {},
|
||||
"PerPage": 10,
|
||||
"DefaultPerPage": 10,
|
||||
"PerPageOptions": [
|
||||
10,
|
||||
25,
|
||||
50
|
||||
],
|
||||
"Target": "#tier-changes-panel",
|
||||
"SyncSelect": "#plan-grants-panel,#entitlement-changes-panel"
|
||||
},
|
||||
"Transitions": [
|
||||
{
|
||||
"TransitionID": "<uuid>",
|
||||
"TransitionType": "upgrade",
|
||||
"LadderName": "Near",
|
||||
"ChangeLabel": "Upgraded",
|
||||
"ChangeTooltip": "",
|
||||
"ChangeKind": "",
|
||||
"FromLabel": "Both",
|
||||
"ToLabel": "Higher",
|
||||
"ActorType": "operator",
|
||||
"ActorName": "Ann",
|
||||
"ReasonLabel": "Complimentary",
|
||||
"ReasonNote": "",
|
||||
"ReasonRaw": "test fixture",
|
||||
"EffectiveAt": {
|
||||
"Date": "<today>",
|
||||
"Time": "<hh:mm>"
|
||||
}
|
||||
},
|
||||
{
|
||||
"TransitionID": "<uuid>",
|
||||
"TransitionType": "initiate",
|
||||
"LadderName": "Far",
|
||||
"ChangeLabel": "Started",
|
||||
"ChangeTooltip": "",
|
||||
"ChangeKind": "",
|
||||
"FromLabel": "—",
|
||||
"ToLabel": "Both",
|
||||
"ActorType": "webhook",
|
||||
"ActorName": "",
|
||||
"ReasonLabel": "test fixture",
|
||||
"ReasonNote": "",
|
||||
"ReasonRaw": "test fixture",
|
||||
"EffectiveAt": {
|
||||
"Date": "<today>",
|
||||
"Time": "<hh:mm>"
|
||||
}
|
||||
},
|
||||
{
|
||||
"TransitionID": "<uuid>",
|
||||
"TransitionType": "initiate",
|
||||
"LadderName": "Near",
|
||||
"ChangeLabel": "Started",
|
||||
"ChangeTooltip": "",
|
||||
"ChangeKind": "",
|
||||
"FromLabel": "—",
|
||||
"ToLabel": "Both",
|
||||
"ActorType": "webhook",
|
||||
"ActorName": "",
|
||||
"ReasonLabel": "test fixture",
|
||||
"ReasonNote": "",
|
||||
"ReasonRaw": "test fixture",
|
||||
"EffectiveAt": {
|
||||
"Date": "<today>",
|
||||
"Time": "<hh:mm>"
|
||||
}
|
||||
}
|
||||
],
|
||||
"TransitionsError": "",
|
||||
"TransitionsTotal": 3
|
||||
}
|
||||
+28
-14
@@ -90,98 +90,112 @@
|
||||
"Name": "Basic",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Forum>",
|
||||
"Name": "Forum",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Forum2>",
|
||||
"Name": "Forum2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Large>",
|
||||
"Name": "Large",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Lite>",
|
||||
"Name": "Lite",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Lite2>",
|
||||
"Name": "Lite2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Mid>",
|
||||
"Name": "Mid",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
"Name": "Plus",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
"Name": "Pro",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Small>",
|
||||
"Name": "Small",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Small2>",
|
||||
"Name": "Small2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Starter>",
|
||||
"Name": "Starter",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Starter2>",
|
||||
"Name": "Starter2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Top>",
|
||||
"Name": "Top",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
}
|
||||
],
|
||||
"Members": [],
|
||||
|
||||
+28
-14
@@ -82,98 +82,112 @@
|
||||
"Name": "Basic",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Forum>",
|
||||
"Name": "Forum",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Forum2>",
|
||||
"Name": "Forum2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Large>",
|
||||
"Name": "Large",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Lite>",
|
||||
"Name": "Lite",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Lite2>",
|
||||
"Name": "Lite2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Mid>",
|
||||
"Name": "Mid",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
"Name": "Plus",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
"Name": "Pro",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Small>",
|
||||
"Name": "Small",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Small2>",
|
||||
"Name": "Small2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Starter>",
|
||||
"Name": "Starter",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Starter2>",
|
||||
"Name": "Starter2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Top>",
|
||||
"Name": "Top",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
}
|
||||
],
|
||||
"Members": [],
|
||||
|
||||
+28
-14
@@ -82,98 +82,112 @@
|
||||
"Name": "Basic",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Forum>",
|
||||
"Name": "Forum",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Forum2>",
|
||||
"Name": "Forum2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Large>",
|
||||
"Name": "Large",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Lite>",
|
||||
"Name": "Lite",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Lite2>",
|
||||
"Name": "Lite2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Mid>",
|
||||
"Name": "Mid",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
"Name": "Plus",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
"Name": "Pro",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Small>",
|
||||
"Name": "Small",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Small2>",
|
||||
"Name": "Small2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Starter>",
|
||||
"Name": "Starter",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Starter2>",
|
||||
"Name": "Starter2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Top>",
|
||||
"Name": "Top",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
}
|
||||
],
|
||||
"Members": [],
|
||||
|
||||
@@ -112,98 +112,112 @@
|
||||
"Name": "Basic",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Forum>",
|
||||
"Name": "Forum",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Forum2>",
|
||||
"Name": "Forum2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Large>",
|
||||
"Name": "Large",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": true
|
||||
"SupersedesSubscription": true,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Lite>",
|
||||
"Name": "Lite",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Lite2>",
|
||||
"Name": "Lite2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Mid>",
|
||||
"Name": "Mid",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
"Name": "Plus",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
"Name": "Pro",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Small>",
|
||||
"Name": "Small",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": true
|
||||
"SupersedesSubscription": true,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Small2>",
|
||||
"Name": "Small2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Starter>",
|
||||
"Name": "Starter",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Starter2>",
|
||||
"Name": "Starter2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Top>",
|
||||
"Name": "Top",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
}
|
||||
],
|
||||
"Members": [
|
||||
|
||||
+28
-14
@@ -98,98 +98,112 @@
|
||||
"Name": "Basic",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Forum>",
|
||||
"Name": "Forum",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Forum2>",
|
||||
"Name": "Forum2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Large>",
|
||||
"Name": "Large",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": true
|
||||
"SupersedesSubscription": true,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Lite>",
|
||||
"Name": "Lite",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Lite2>",
|
||||
"Name": "Lite2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Mid>",
|
||||
"Name": "Mid",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
"Name": "Plus",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
"Name": "Pro",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Small>",
|
||||
"Name": "Small",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": true
|
||||
"SupersedesSubscription": true,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Small2>",
|
||||
"Name": "Small2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Starter>",
|
||||
"Name": "Starter",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Starter2>",
|
||||
"Name": "Starter2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Top>",
|
||||
"Name": "Top",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
}
|
||||
],
|
||||
"Members": [
|
||||
|
||||
+28
-14
@@ -82,98 +82,112 @@
|
||||
"Name": "Basic",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Forum>",
|
||||
"Name": "Forum",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Forum2>",
|
||||
"Name": "Forum2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Large>",
|
||||
"Name": "Large",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Lite>",
|
||||
"Name": "Lite",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Lite2>",
|
||||
"Name": "Lite2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Mid>",
|
||||
"Name": "Mid",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
"Name": "Plus",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
"Name": "Pro",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Small>",
|
||||
"Name": "Small",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Small2>",
|
||||
"Name": "Small2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Starter>",
|
||||
"Name": "Starter",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Starter2>",
|
||||
"Name": "Starter2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Top>",
|
||||
"Name": "Top",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
}
|
||||
],
|
||||
"Members": [],
|
||||
|
||||
@@ -82,98 +82,112 @@
|
||||
"Name": "Basic",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Forum>",
|
||||
"Name": "Forum",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Forum2>",
|
||||
"Name": "Forum2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Large>",
|
||||
"Name": "Large",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Lite>",
|
||||
"Name": "Lite",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Lite2>",
|
||||
"Name": "Lite2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Mid>",
|
||||
"Name": "Mid",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Plus>",
|
||||
"Name": "Plus",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Pro>",
|
||||
"Name": "Pro",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Small>",
|
||||
"Name": "Small",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Small2>",
|
||||
"Name": "Small2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": true
|
||||
"SupersedesSubscription": true,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Starter>",
|
||||
"Name": "Starter",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Starter2>",
|
||||
"Name": "Starter2",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Top>",
|
||||
"Name": "Top",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": false
|
||||
"SupersedesSubscription": false,
|
||||
"SupersededLadders": null
|
||||
}
|
||||
],
|
||||
"Members": [],
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
|
||||
|
||||
<div class="d-flex align-items-start justify-content-between gap-3 mb-3 app-page-header">
|
||||
<div class="app-page-header-text">
|
||||
<nav aria-label="breadcrumb"><ol class="breadcrumb small mb-1">
|
||||
<li class="breadcrumb-item"><a href="/operator">Operator</a></li><li class="breadcrumb-item"><a href="/operator/organizations">Organizations</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">two-ladder-subscription</li>
|
||||
</ol></nav>
|
||||
<h1 class="h2 mb-0">two-ladder-subscription</h1>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-3">Owner</dt>
|
||||
<dd class="col-sm-9">
|
||||
|
||||
<a href="/operator/persons/<person:Ann>">Ann</a>
|
||||
|
||||
</dd>
|
||||
|
||||
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 mt-4 mb-2 app-section-header">
|
||||
<h2 class="h5 mb-0">Billing</h2>
|
||||
|
||||
<a href="/operator/billing/accounts?q=two-ladder-subscription+" class="btn btn-sm btn-outline-secondary">Full billing</a>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
|
||||
|
||||
<div class="row g-3">
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="text-muted small">Outstanding balance</div>
|
||||
|
||||
<div>0.00</div>
|
||||
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-muted small">Subscriptions</div>
|
||||
|
||||
<div>
|
||||
<span class="badge text-bg-success">Active</span>
|
||||
<a href="/operator/billing/subscriptions?q=two-ladder-subscription+">1 subscription</a>
|
||||
|
||||
<small class="text-muted">· renews Jan 15, 2099</small>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-muted small">Latest invoice</div>
|
||||
|
||||
<div class="text-muted small">No invoices issued</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 mt-4 mb-2 app-section-header">
|
||||
<h2 class="h5 mb-0">Pools <button type="button" class="btn btn-link p-0 border-0 align-baseline app-help-icon" data-bs-toggle="popover" data-bs-trigger="click" data-bs-title="Pools" data-bs-content="A pool holds what an organization's plan and grants deliver; its sites and services draw from it." aria-label="Help: Pools"><svg class="form-help-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></button></h2>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h3 class="h6 mb-1">
|
||||
Default <span class="badge text-bg-success">Active</span>
|
||||
</h3>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-2">
|
||||
<p class="mb-0"><strong>Both</strong></p>
|
||||
<p class="mb-0 small text-muted">
|
||||
<span title="since <today> <hh:mm>">Near, rank 0</span> · <span title="since <today> <hh:mm>">Far, rank 0</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 mt-4 mb-2 app-section-header">
|
||||
<h2 class="h5 mb-0">Grants</h2>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div id="plan-grants-panel">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center gap-2">
|
||||
|
||||
<ul class="nav nav-tabs card-header-tabs">
|
||||
|
||||
<li class="nav-item"><a class="nav-link active" href="/operator/organizations/<org:two-ladder-subscription>" hx-get="/operator/organizations/<org:two-ladder-subscription>" hx-target="#plan-grants-panel" hx-select="#plan-grants-panel" hx-select-oob="#tier-changes-panel,#entitlement-changes-panel" hx-swap="outerHTML" hx-push-url="true">Active</a></li>
|
||||
|
||||
<li class="nav-item"><a class="nav-link " href="/operator/organizations/<org:two-ladder-subscription>?tab=history" hx-get="/operator/organizations/<org:two-ladder-subscription>?tab=history" hx-target="#plan-grants-panel" hx-select="#plan-grants-panel" hx-select-oob="#tier-changes-panel,#entitlement-changes-panel" hx-swap="outerHTML" hx-push-url="true">History</a></li>
|
||||
|
||||
</ul>
|
||||
|
||||
<div class="flex-shrink-0">
|
||||
|
||||
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-toggle="collapse" data-bs-target="#issueGrantPanel" aria-expanded="false" aria-controls="issueGrantPanel"><svg class="app-opener-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true" focusable="false"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Issue grant</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
|
||||
<div class="collapse" id="issueGrantPanel">
|
||||
|
||||
|
||||
<div class="card app-form-box mb-3"><div class="card-body">
|
||||
<form id="form-operator.enrollment.grant.issue" data-form="operator.enrollment.grant.issue" data-form-kind="create" class="app-form" novalidate hx-post="/partials/operator/organizations/<org:two-ladder-subscription>/grant/create" hx-target="#operator-body" hx-swap="innerHTML" hx-disable="find button[type=submit]" hx-indicator="find .htmx-indicator">
|
||||
<div id="form-operator.enrollment.grant.issue-error" class="app-form-error" role="alert"></div>
|
||||
|
||||
<div class="app-form-message mb-2">A tier product moves the pool to that tier, up or down.</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-form-fields">
|
||||
<div id="form-operator.enrollment.grant.issue-product_id" class="mb-3 app-form-field">
|
||||
|
||||
|
||||
<label class="form-label" for="form-operator.enrollment.grant.issue-product_id-control">Product</label>
|
||||
|
||||
<select class="form-select" id="form-operator.enrollment.grant.issue-product_id-control" name="product_id" required data-hint-toggle="form-operator.enrollment.grant.issue-product_id-hint" aria-describedby="form-operator.enrollment.grant.issue-product_id-warning" hx-get="/partials/operator/products/rule-check" hx-swap="innerHTML" hx-target="#form-operator\.enrollment\.grant\.issue-product_id-warning" hx-trigger="change">
|
||||
<option value="" disabled selected>Choose a product</option>
|
||||
<option value="<product:Both>" data-shows-hint data-hint="This grant would supersede a tier a subscription pays for on Far and Near; its billing pauses until the grant ends.">Both ⚠</option>
|
||||
<option value="<product:Higher>" data-shows-hint data-hint="This grant would supersede a tier a subscription pays for on Far and Near; its billing pauses until the grant ends.">Higher ⚠</option>
|
||||
|
||||
</select>
|
||||
|
||||
<div id="form-operator.enrollment.grant.issue-product_id-warning"></div>
|
||||
|
||||
<div id="form-operator.enrollment.grant.issue-product_id-hint" class="form-text" data-hint-default="This grant would supersede a tier a subscription pays for; its billing pauses until the grant ends." hidden>This grant would supersede a tier a subscription pays for; its billing pauses until the grant ends.</div>
|
||||
|
||||
|
||||
</div><div id="form-operator.enrollment.grant.issue-quantity" class="mb-3 app-form-field">
|
||||
|
||||
|
||||
<label class="form-label" for="form-operator.enrollment.grant.issue-quantity-control">Quantity <span class="text-muted">(optional)</span></label> <button type="button" class="btn btn-link p-0 border-0 align-baseline app-help-icon" data-bs-toggle="popover" data-bs-trigger="click" data-bs-title="Quantity" data-bs-content="Seats, for per-seat products." aria-label="Help: Quantity"><svg class="form-help-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></button>
|
||||
|
||||
<input type="number" class="form-control" id="form-operator.enrollment.grant.issue-quantity-control" name="quantity" value="" placeholder="1" min="1" max="1000000">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div><div id="form-operator.enrollment.grant.issue-valid_until" class="mb-3 app-form-field">
|
||||
|
||||
|
||||
<label class="form-label" for="form-operator.enrollment.grant.issue-valid_until-control">Valid until <span class="text-muted">(optional)</span></label> <button type="button" class="btn btn-link p-0 border-0 align-baseline app-help-icon" data-bs-toggle="popover" data-bs-trigger="click" data-bs-title="Valid until" data-bs-content="Leave blank for a grant with no end date." aria-label="Help: Valid until"><svg class="form-help-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></button>
|
||||
|
||||
<input type="datetime-local" class="form-control" id="form-operator.enrollment.grant.issue-valid_until-control" name="valid_until" value="" >
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div><div id="form-operator.enrollment.grant.issue-reason" class="mb-3 app-form-field">
|
||||
|
||||
|
||||
<label class="form-label" for="form-operator.enrollment.grant.issue-reason-control">Reason</label>
|
||||
|
||||
<select class="form-select" id="form-operator.enrollment.grant.issue-reason-control" name="reason" required>
|
||||
<option value="" disabled selected>Choose a reason</option>
|
||||
<option value="manual">manual</option>
|
||||
<option value="evaluation">evaluation</option>
|
||||
<option value="promotional">promotional</option>
|
||||
<option value="complimentary">complimentary</option>
|
||||
<option value="sponsored">sponsored</option>
|
||||
<option value="board_decision">board_decision</option>
|
||||
<option value="legacy">legacy</option>
|
||||
|
||||
</select>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div><div id="form-operator.enrollment.grant.issue-description" class="mb-3 app-form-field">
|
||||
|
||||
|
||||
<label class="form-label" for="form-operator.enrollment.grant.issue-description-control">Note <span class="text-muted">(optional)</span></label> <button type="button" class="btn btn-link p-0 border-0 align-baseline app-help-icon" data-bs-toggle="popover" data-bs-trigger="click" data-bs-title="Note" data-bs-content="Why this grant is issued. Shown with the grant in its history." aria-label="Help: Note"><svg class="form-help-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></button>
|
||||
|
||||
<input type="text" class="form-control" id="form-operator.enrollment.grant.issue-description-control" name="description" value="" maxlength="500" autocomplete="off">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div><div id="form-operator.enrollment.grant.issue-resumes_after_supersession" class="mb-3 app-form-field">
|
||||
|
||||
|
||||
|
||||
<label class="form-label" for="form-operator.enrollment.grant.issue-resumes_after_supersession-control">Resumes</label> <button type="button" class="btn btn-link p-0 border-0 align-baseline app-help-icon" data-bs-toggle="popover" data-bs-trigger="click" data-bs-title="Resumes" data-bs-content="If a plan replaces this grant, the grant delivers again when that plan ends." aria-label="Help: Resumes"><svg class="form-help-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></button>
|
||||
|
||||
<div class="app-form-check-control"><input class="form-check-input" type="checkbox" id="form-operator.enrollment.grant.issue-resumes_after_supersession-control" name="resumes_after_supersession" value="true" ></div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 app-form-commit">
|
||||
|
||||
<button type="submit" class="btn btn-primary"><span class="spinner-border spinner-border-sm htmx-indicator me-1" aria-hidden="true"></span>Issue grant</button>
|
||||
|
||||
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="collapse" data-bs-target="#issueGrantPanel">Cancel</button>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div></div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<form id="form-operator.list.controls" data-form="operator.list.controls" data-form-kind="search" class="app-form app-form-bar" method="GET" action="/operator/organizations/<org:two-ladder-subscription>" role="search" hx-get="/operator/organizations/<org:two-ladder-subscription>" hx-target="#plan-grants-panel" hx-swap="outerHTML" hx-push-url="true" hx-select="#plan-grants-panel" hx-select-oob="#tier-changes-panel,#entitlement-changes-panel">
|
||||
<div id="form-operator.list.controls-error" class="app-form-error" role="alert"></div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<label class="form-label visually-hidden" for="form-operator.list.controls-q-control">Search this list</label>
|
||||
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true"><path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.44.44.98.98l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.98-.98zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0"/></svg></span>
|
||||
<input type="text" class="form-control" id="form-operator.list.controls-q-control" name="q" value="" placeholder="Product or reason" autocomplete="off">
|
||||
|
||||
|
||||
<button type="submit" class="btn btn-outline-secondary"><span class="spinner-border spinner-border-sm htmx-indicator me-1" aria-hidden="true"></span>Search</button>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="text-center py-4">
|
||||
<p class="text-muted mb-2">No grants are delivering right now.</p>
|
||||
<p class="text-muted small mb-2">The History tab lists every grant ever issued.</p>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 mt-4 mb-2 app-section-header">
|
||||
<h2 class="h5 mb-0">Members</h2>
|
||||
|
||||
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="text-center py-4">
|
||||
<p class="text-muted mb-2">No members.</p>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 mt-4 mb-2 app-section-header">
|
||||
<h2 class="h5 mb-0">Entitlement changes</h2>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div id="entitlement-changes-panel">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="text-center py-4">
|
||||
<p class="text-muted mb-2">No entitlement changes recorded yet.</p>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 mt-4 mb-2 app-section-header">
|
||||
<h2 class="h5 mb-0">Tier changes <button type="button" class="btn btn-link p-0 border-0 align-baseline app-help-icon" data-bs-toggle="popover" data-bs-trigger="click" data-bs-title="Tier changes" data-bs-content="Every tier change on this organization's plan ladders, newest first. Tier names reflect the ladder's current shape." aria-label="Help: Tier changes"><svg class="form-help-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></button></h2>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div id="tier-changes-panel">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm table-record">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ladder</th>
|
||||
<th>Change</th>
|
||||
<th>From</th>
|
||||
<th>To</th>
|
||||
<th>By</th>
|
||||
<th>Reason</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<th scope="row">Far</th>
|
||||
<td><span class="badge text-bg-light border">Started</span></td>
|
||||
<td>—</td>
|
||||
<td>Both</td>
|
||||
<td>
|
||||
<em class="text-muted">webhook</em>
|
||||
</td>
|
||||
<td class="text-break" title="test fixture">test fixture</td>
|
||||
<td class="text-nowrap"><small><span class="d-block"><today></span><span class="d-block text-muted"><hh:mm></span></small></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th scope="row">Near</th>
|
||||
<td><span class="badge text-bg-light border">Started</span></td>
|
||||
<td>—</td>
|
||||
<td>Both</td>
|
||||
<td>
|
||||
<em class="text-muted">webhook</em>
|
||||
</td>
|
||||
<td class="text-break" title="test fixture">test fixture</td>
|
||||
<td class="text-nowrap"><small><span class="d-block"><today></span><span class="d-block text-muted"><hh:mm></span></small></td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mt-2">
|
||||
<p class="small text-body-secondary mb-0">Showing 1–2 of 2</p>
|
||||
|
||||
<p class="small text-body-secondary mb-0">Per page:
|
||||
|
||||
<strong>10</strong>
|
||||
|
||||
<a href="/operator/organizations/<org:two-ladder-subscription>?tc_per=25" hx-get="/operator/organizations/<org:two-ladder-subscription>?tc_per=25" hx-target="#tier-changes-panel" hx-select="#tier-changes-panel" hx-select-oob="#plan-grants-panel,#entitlement-changes-panel" hx-swap="outerHTML" hx-push-url="true">25</a>
|
||||
|
||||
<a href="/operator/organizations/<org:two-ladder-subscription>?tc_per=50" hx-get="/operator/organizations/<org:two-ladder-subscription>?tc_per=50" hx-target="#tier-changes-panel" hx-select="#tier-changes-panel" hx-select-oob="#plan-grants-panel,#entitlement-changes-panel" hx-swap="outerHTML" hx-push-url="true">50</a>
|
||||
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
{
|
||||
"ActionError": "",
|
||||
"BillingSummary": {
|
||||
"HasAccount": true,
|
||||
"BillingAccountID": "<uuid>",
|
||||
"AccountStatus": "active",
|
||||
"SubscriptionCount": 1,
|
||||
"SubscriptionState": "active",
|
||||
"SubscriptionNote": "renews Jan 15, 2099",
|
||||
"DisplacementTitle": "",
|
||||
"DisplacementError": "",
|
||||
"HasInvoice": false,
|
||||
"LatestInvoiceID": "",
|
||||
"LatestInvoiceNumber": "",
|
||||
"LatestInvoiceDate": "",
|
||||
"InvoiceState": "",
|
||||
"OutstandingBalance": 0,
|
||||
"OutstandingBalanceFormatted": "0.00",
|
||||
"Currency": ""
|
||||
},
|
||||
"EntitlementChanges": [],
|
||||
"EntitlementChangesError": "",
|
||||
"EntitlementChangesNav": {
|
||||
"BasePath": "/operator/organizations/<org:two-ladder-subscription>",
|
||||
"SearchPlaceholder": "",
|
||||
"FacetParam": "",
|
||||
"FacetOptions": null,
|
||||
"Q": "",
|
||||
"Facet": "",
|
||||
"Page": 1,
|
||||
"Total": 0,
|
||||
"ParamPrefix": "ec_",
|
||||
"Extra": {},
|
||||
"PerPage": 10,
|
||||
"DefaultPerPage": 10,
|
||||
"PerPageOptions": [
|
||||
10,
|
||||
25,
|
||||
50
|
||||
],
|
||||
"Target": "#entitlement-changes-panel",
|
||||
"SyncSelect": "#plan-grants-panel,#tier-changes-panel"
|
||||
},
|
||||
"EntitlementChangesTotal": 0,
|
||||
"Error": "",
|
||||
"GrantsError": "",
|
||||
"GrantsListQuery": "",
|
||||
"GrantsNav": {
|
||||
"BasePath": "/operator/organizations/<org:two-ladder-subscription>",
|
||||
"SearchPlaceholder": "Product or reason",
|
||||
"FacetParam": "tab",
|
||||
"FacetOptions": [
|
||||
{
|
||||
"Value": "",
|
||||
"Label": "Active"
|
||||
},
|
||||
{
|
||||
"Value": "history",
|
||||
"Label": "History"
|
||||
}
|
||||
],
|
||||
"Q": "",
|
||||
"Facet": "",
|
||||
"Page": 1,
|
||||
"Total": 0,
|
||||
"ParamPrefix": "",
|
||||
"Extra": {},
|
||||
"PerPage": 10,
|
||||
"DefaultPerPage": 10,
|
||||
"PerPageOptions": [
|
||||
10,
|
||||
25,
|
||||
50
|
||||
],
|
||||
"Target": "#plan-grants-panel",
|
||||
"SyncSelect": "#tier-changes-panel,#entitlement-changes-panel"
|
||||
},
|
||||
"GrantsTotal": 0,
|
||||
"IssuanceProducts": [
|
||||
{
|
||||
"ProductID": "<product:Both>",
|
||||
"Name": "Both",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": true,
|
||||
"SupersededLadders": [
|
||||
"Far",
|
||||
"Near"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ProductID": "<product:Higher>",
|
||||
"Name": "Higher",
|
||||
"IsInternal": false,
|
||||
"DisplayCategory": "",
|
||||
"SupersedesSubscription": true,
|
||||
"SupersededLadders": [
|
||||
"Far",
|
||||
"Near"
|
||||
]
|
||||
}
|
||||
],
|
||||
"Members": [],
|
||||
"MembersCapped": false,
|
||||
"MembersError": "",
|
||||
"MembersTotal": 0,
|
||||
"OrgID": "<org:two-ladder-subscription>",
|
||||
"OrgKey": "",
|
||||
"OrgName": "two-ladder-subscription",
|
||||
"OrgOwnerName": "Ann",
|
||||
"OrgOwnerPersonID": "<person:Ann>",
|
||||
"PoolMissing": false,
|
||||
"Pools": [
|
||||
{
|
||||
"PoolID": "<pool:two-ladder-subscription>",
|
||||
"PoolName": "Default",
|
||||
"PoolType": "default",
|
||||
"Status": "active",
|
||||
"Deliveries": [
|
||||
{
|
||||
"ProvisionID": "<uuid>",
|
||||
"ProductName": "Both",
|
||||
"ProductID": "<product:Both>",
|
||||
"Rungs": [
|
||||
{
|
||||
"LadderName": "Near",
|
||||
"Rank": 0,
|
||||
"ActivatedAt": "<today> <hh:mm>"
|
||||
},
|
||||
{
|
||||
"LadderName": "Far",
|
||||
"Rank": 0,
|
||||
"ActivatedAt": "<today> <hh:mm>"
|
||||
}
|
||||
],
|
||||
"Suspended": false
|
||||
}
|
||||
],
|
||||
"Usage": null,
|
||||
"HasAttachment": true
|
||||
}
|
||||
],
|
||||
"ProductsError": "",
|
||||
"TierChangesNav": {
|
||||
"BasePath": "/operator/organizations/<org:two-ladder-subscription>",
|
||||
"SearchPlaceholder": "",
|
||||
"FacetParam": "",
|
||||
"FacetOptions": null,
|
||||
"Q": "",
|
||||
"Facet": "",
|
||||
"Page": 1,
|
||||
"Total": 2,
|
||||
"ParamPrefix": "tc_",
|
||||
"Extra": {},
|
||||
"PerPage": 10,
|
||||
"DefaultPerPage": 10,
|
||||
"PerPageOptions": [
|
||||
10,
|
||||
25,
|
||||
50
|
||||
],
|
||||
"Target": "#tier-changes-panel",
|
||||
"SyncSelect": "#plan-grants-panel,#entitlement-changes-panel"
|
||||
},
|
||||
"Transitions": [
|
||||
{
|
||||
"TransitionID": "<uuid>",
|
||||
"TransitionType": "initiate",
|
||||
"LadderName": "Far",
|
||||
"ChangeLabel": "Started",
|
||||
"ChangeTooltip": "",
|
||||
"ChangeKind": "",
|
||||
"FromLabel": "—",
|
||||
"ToLabel": "Both",
|
||||
"ActorType": "webhook",
|
||||
"ActorName": "",
|
||||
"ReasonLabel": "test fixture",
|
||||
"ReasonNote": "",
|
||||
"ReasonRaw": "test fixture",
|
||||
"EffectiveAt": {
|
||||
"Date": "<today>",
|
||||
"Time": "<hh:mm>"
|
||||
}
|
||||
},
|
||||
{
|
||||
"TransitionID": "<uuid>",
|
||||
"TransitionType": "initiate",
|
||||
"LadderName": "Near",
|
||||
"ChangeLabel": "Started",
|
||||
"ChangeTooltip": "",
|
||||
"ChangeKind": "",
|
||||
"FromLabel": "—",
|
||||
"ToLabel": "Both",
|
||||
"ActorType": "webhook",
|
||||
"ActorName": "",
|
||||
"ReasonLabel": "test fixture",
|
||||
"ReasonNote": "",
|
||||
"ReasonRaw": "test fixture",
|
||||
"EffectiveAt": {
|
||||
"Date": "<today>",
|
||||
"Time": "<hh:mm>"
|
||||
}
|
||||
}
|
||||
],
|
||||
"TransitionsError": "",
|
||||
"TransitionsTotal": 2
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-30
|
||||
@@ -0,0 +1,187 @@
|
||||
## Context
|
||||
|
||||
Line numbers are at HEAD `41ab2a1a`. The examples use the Products page golden's catalog (`internal/server/member_plans_golden_db_test.go:140-160`): Plus is a tier of the Wiki ladder and of the Duo ladder, and the states `paid-plus-under-granted-pro*` (`:269-302`) put a Pro grant (a Wiki tier only) over a subscription to Plus.
|
||||
|
||||
**What a grant on one ladder does.** `core.confer` ends every rung the superseded provision held, "uncontested rungs included" (`internal/db/migrations/00023_grant_resumption.sql:600-617`; invariant 7, `docs/models/plan-ladders-transitions.md:67`). The Pro grant on Wiki therefore ends the Plus provision on Wiki and on Duo. `ReapplyDefaultsIfVacant` restores the organization type's default only when the pool holds nothing on any ladder (`internal/entitlements/reapply_defaults.go:84-96`), so Duo stays empty while the grant holds Wiki.
|
||||
|
||||
**What the reconcile does with it, and whether billing pauses.** It does, by this chain:
|
||||
|
||||
1. `core.subscription_displacements` names a ladder only where the subscription's latest episode ended in a supersession pair whose successor is another source (`00037_displacement_holder_rank.sql:40-53`) and an active or suspended non-default position holds it now (`:54-69`). A pair needs the successor's typed row on the same ladder at the same instant (`00033_supersession_pairs.sql:35-43`). The Pro grant writes rows on Wiki only, so the function returns one row, for Wiki.
|
||||
2. `productsLeftAlone` takes Plus as missing and calls `DisplacedProducts` (`internal/fulfillment/reconcile.go:976-1000`, the call at `:999`). `DisplacedProducts` maps a missing product to the first of its rungs whose ladder has a displacement that does not yield (`internal/entitlements/displacement.go:108-125`). Duo has no row; Wiki's row is a grant of Pro, which does not yield to Plus, the product it displaced (`:131-136`). Plus is left alone whole, on both ladders. The rule's own test row pins the shape: "a bundle whose second rung is displaced is left out" (`internal/entitlements/displacement_test.go:65-70`).
|
||||
3. The active branch asks `CollectionPauseWanted(status, desired, left)` (`reconcile.go:173`). It wants a pause when the status is `active`, `trialing`, `past_due` or `unpaid`, at least one current item confers a product, every such product is in `left`, and every displacement in `left` is a grant's (`internal/entitlements/collection.go:20-39`). For a subscription that pays for Plus alone all four hold, so the reconcile asks for the pause, and the outbox's reconcile sets it. A subscription that also pays for a product that still delivers keeps billing (the spec's scenario "A partly displaced subscription keeps billing").
|
||||
4. When the grant ends, the delivery step confers the subscription's recorded products that `DisplacedProducts` no longer leaves alone (`internal/entitlements/subscription_delivery.go:208-230`); Plus is conferred whole, on Wiki and Duo.
|
||||
|
||||
So the strings below may say that billing pauses.
|
||||
|
||||
**What each surface says today.**
|
||||
|
||||
- **The Issue grant form.** `subscriptionHeldLadders` reads every ladder a subscription-sourced position holds (`internal/server/operator_enrollment.go:2144-2158`), `productsOnLadders` marks every product that is a tier of one of them (`:2123-2136`), and `loadIssuanceProducts` sets `SupersedesSubscription` (`:2092-2117`). Each marked option carries ⚠ and `ShowsHint` (`internal/server/operator_enrollment_forms.go:198-211`), and the Product field's one hint, shown once a marked product is chosen, is Copy W of the archived `grant-pauses-subscription-billing` change: "This grant would supersede a tier a subscription pays for; its billing pauses until the grant ends." (`:130-138`, the field at `:221-235`). It names no ladder. For the golden's organization, Free, Basic, Plus, Standard, Pro (Wiki) and Duo Max (Duo) are all marked, since the Plus provision holds both ladders.
|
||||
- **The organization page.** The Billing card's Subscriptions line carries the Not delivering badge after its state badge (`internal/embeds/templates/partials/operator_enrollment.html:86-101`; `BillingSummaryViewModel.DisplacementBadge`, `internal/server/anatomy.go:733-744`). The line holds no ladder name; the badge's title is the only text that names one. `fillDisplacement` (`operator_enrollment.go:1821-1836`) reads the account's items, their shapes and displacements (`accountDisplacementTitle`, `:1852-1873`) and words the title through `displacementTitleFor` (`:1921-1935`), `displacedByID`, `displacedOnPage` (`:501-516`) and `displacementTitle` (`:610-621`). `displacedOnPage` appends, per product left alone, the ladder of the one displacement that decided it (`:510`), so the title reads "Billing paused while a grant holds Wiki." and never names Duo. The spec says so outright: "For a product of several ladders the title SHALL name the displaced ladder the function picked, not the product's other ladders" (`openspec/specs/plan-enrollment-administration/spec.md:1034`).
|
||||
- **The subscriptions list.** `subscriptionTitlesFor` (`internal/server/operator_billing.go:642-657`) calls the same `displacedByID` and `displacementTitle`, one subscription per row, so it reads the same title. The list reads no position today (`subscriptionDisplacementTitles`, `:615-636`: the current items, the shapes and the displacements).
|
||||
- **The member's Products page.** On Duo, `payingOn` finds the Plus subscription paying for a tier without holding (`internal/server/plan_offer.go:91-107`), with `GrantHolds` false, because nothing holds Duo (`payingFrom`, `:190-203`, the test at `:194`). `buildLadder` builds the paid card's facts only when a grant holds the ladder (`internal/server/member_plans.go:407-411`), so Duo's Plus card is an ordinary card: rule 3a answers none (`PaidHere`), and the card reads the tier's public price with no control. The golden shows it: `internal/server/testdata/member_plans/paid-plus-under-granted-pro.html:178-183`, "$10.00/month". The archived change's D10 says the same ("On a ladder no plan holds (a bundle's vacated ladder) the paid tier draws its price, as every card does, and no control"), and so does the spec ("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", `member-product-discovery`, `:761`). Nothing on Duo says why it is empty while Plus is paid for.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Whole-product supersession stays: a grant on one ladder of a subscription's product ends the product on every ladder, and the other ladders stay empty until the grant ends.
|
||||
- The grant form, the organization page, the subscriptions list and the member's Products page name every ladder the subscription delivers nothing on.
|
||||
- The model document states the rule beside whole-bundle supersession, and a test drives a grant over one ladder of a two-ladder subscription.
|
||||
|
||||
**Non-Goals:**
|
||||
- Any change to who holds a ladder, to the pause rule or to what Stripe is sent.
|
||||
- The three options the ruling rejected (Rejected).
|
||||
- The card on a ladder a later plan holds after the grant emptied it, and a ladder emptied by another subscription's hold (Failure table F7, F8).
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1. The rule stays, and a test pins it
|
||||
|
||||
The ruling keeps whole-product supersession. Nothing in conferral, the reconcile or the pause changes. One database test drives the case end to end (Tests, T1): the fixture's tier made a tier of a second ladder, the subscription reconciled onto both, a grant of another tier of the first ladder issued over it, then the subscription's reconcile. The subscription holds no live provision, the second ladder holds no position of the pool, and the reconcile wrote one `reconcile_subscription` entry, the pause request (`TestReconcile_AGrantOverTheSubscriptionRequestsThePause`'s check, `internal/fulfillment/displaced_subscription_test.go:145-160`).
|
||||
|
||||
### D2. The Not delivering badge's title names every ladder
|
||||
|
||||
**The ladders.** `displacedSubscription` (`operator_enrollment.go:486-490`) gains `Emptied []displacedLadder`. `displacedOnPage` (`:501-516`) gains a last parameter, `live map[string]bool`, the ids of the ladders on which a live position exists when the page renders: an active or suspended position of any source, the rows `ListLadderPositionsByPool` returns. It keeps its rule call and its `Paused` test, and for each product `DisplacedProducts` leaves alone it walks every rung of the product, in rung order:
|
||||
|
||||
- a rung whose ladder has a displacement row of the subscription (indexed by ladder, keeping the first row, as `DisplacedProducts` indexes them) adds that ladder and that row's holder, once per ladder, as the deciding ladder is added today;
|
||||
- a rung with no row whose ladder is not in `live` adds its ladder to `Emptied`;
|
||||
- a rung with no row whose ladder is in `live` adds nothing: something else holds it.
|
||||
|
||||
So a bundle a grant holds on both ladders names both as held, a bundle the grant holds on one names the other as emptied, and a product delivers nothing anywhere once it is left alone, so each of its ladders is named in the holder clause or the emptied clause unless something else holds it. An **emptied ladder** is a ladder of a displaced product on which no live position exists when the page renders; this is its one definition.
|
||||
|
||||
**Where `live` comes from.** The organization page passes the ladder ids of the organization's active default pool's positions, which `loadOrgPools` already reads for the Pools panel (`:1566-1578`); `loadOrgPools` returns them beside the pools, and they reach `displacedOnPage` through `fillDisplacement`, `accountDisplacementTitle` and `displacementTitleFor`. No statement is added. A failed read of those positions is a failed read of the card (F6). The subscriptions list passes, per subscription, the live ladder ids of its organization's active default pool, read by one new statement per page, `ListLiveLadderIDsBySubscriptionIDs` (in `internal/entitlements/queries/pool_provision_transitions.sql`, beside `ListSubscriptionDisplacementsBySubscriptionIDs` and joining the same default pool), which returns the subscription id and the ladder id of each live position; `subscriptionDisplacementTitles` runs it with the shapes and the displacements, once for the page and never per row, and `subscriptionTitlesFor` and `displacedByID` take its answer as a map of live ladder ids by subscription id.
|
||||
|
||||
**The ladder's name.** A displacement row carries its ladder's name; a rung does not (`Rung`, `internal/entitlements/displacement.go:12-16`, from `ListConferralShapes`, `internal/entitlements/queries/pool_provision_ladders.sql:293-303`, which selects the product, the ladder id and the rank). `ListConferralShapes` gains `l.name AS ladder_name` through `JOIN core.plan_ladders l ON l.plan_ladder_id = s.plan_ladder_id`, and `Rung` gains `LadderName`, which `RungsByProduct` sets. The organization page and the list already read this statement, so neither reads more. The reconcile, the delivery step and Resume read it too and ignore the name.
|
||||
|
||||
**The words.** The sentence ends its holder clause where it ends today and adds the emptied ladders as what is delivered nowhere:
|
||||
|
||||
- `Billing paused while a grant holds Wiki; nothing is delivered on Duo.`
|
||||
- `Billing continues while a grant holds Wiki; nothing is delivered on Duo.`
|
||||
- `Billing paused on 1 of 3 subscriptions while a grant holds Wiki; nothing is delivered on Duo.`
|
||||
|
||||
The clause "; nothing is delivered on {emptied}" goes before the period of the paused sentence and of the continuing sentence, in each of their forms (`titleGroup.pausedSentence` and `continuingSentence`, `:570-590`). The holder phrase is counted by provision as today (`pausedHolder`, `displacementHolder`, `:626-646`) and the clause does not depend on it or on how many subscriptions the sentence counts. {emptied} is the group's distinct emptied ladders, sorted and joined as {ladders} are (`groupDisplaced`, `:528-557`; `joinWithAnd`, `internal/server/operator_partials.go:840`). A ladder that is not emptied (something else holds it, see the definition above) is named in neither clause, so the sentence stays true and the holder clause still names only what displaced the subscription. A group with no emptied ladder reads exactly as today. In the composite form the clause attaches to the sentence of the subscription whose ladders are emptied.
|
||||
|
||||
**Why "nothing is delivered on".** It says what the member's organization gets on that ladder, in the words the operator would use. The definition of an emptied ladder keeps it true at render time: the title never says nothing is delivered on a ladder the Pools panel on the same page shows held. The holder clause names only what displaced the subscription; naming a later, unrelated holder there would read as a cause of the pause.
|
||||
|
||||
**Paused.** `Paused` stays the record of a pause and every deciding displacement (`left[p]`) a grant's (`:514`). It is computed from `left[p]` only, as today, because `CollectionPauseWanted` reads only `left`. The held ladders the walk adds can have a holder `left[p]` does not: migration `00037_displacement_holder_rank.sql:60-64` joins each ladder's current live holder, not the pair's successor. Reachable: a bundle grant G holds Wiki and Duo over Plus; a subscription S2 takes Duo, which ends G on both; Pro is then granted on the now empty Wiki. The Plus subscription's rows are Wiki (a grant) and Duo (a subscription), the reconcile pauses (`left[p]` is Wiki's row, a grant's), and `Paused` is true. A `grantsOnly` taken over every named holder would instead word "Billing continues" while Stripe records the pause. So `titleGroup.pausedSentence` words the holders through `pausedHolder` when every holder the group names is a grant, and through `displacementHolder`'s words otherwise: "Billing paused while other plans hold Duo and Wiki." (F11).
|
||||
|
||||
### D3. The grant form's hint names the ladders of a superseded bundle
|
||||
|
||||
**Which ladders.** For a product the operator may choose, the ladders it would end a subscription's tiers on are every ladder held by each subscription-sourced provision that holds a ladder the product is a tier of: `core.confer` supersedes that provision and ends every rung it holds. A pure function, `supersededLadders(positions []entitlements.ListLadderPositionsByPoolRow, tiers []billing.ListTiersWithProductsByLadderIDsRow) map[string][]string`, maps each product to those ladder ids and reads no row. `subscriptionHeldLadders` becomes `subscriptionPositions`, returning the subscription-sourced position rows it reads today, and `productsOnLadders` becomes `tiersOnLadders`, returning the tier rows of `ListTiersWithProductsByLadderIDs` it reads today. `supersededLadders` answers no entry for a product unless some provision the product would supersede holds two or more ladders; then the entry is every ladder those provisions hold (a product whose superseded provisions each hold one ladder has none, and its option shows Copy W). `loadIssuanceProducts` sets `SupersedesSubscription` when the product is a tier of a ladder a subscription provision holds, which is today's marker and comes from `tiersOnLadders`, and `IssuanceProductOption` gains `SupersededLadders []string`, the ladders' names sorted by `strings.Compare`. Both reads stay as they are.
|
||||
|
||||
**The names.** `loadIssuanceProducts` takes the page's `*operatorNames` and names each ladder through `names.Ladder`. On the organization page the Pools panel has already asked for every position's ladder (`loadOrgPools`, `operator_enrollment.go:1570-1578`), and every ladder a subscription provision holds is a position's ladder, so the lookup reads nothing more; `TestOperatorPagesReadAFixedNumberOfTimes` stays as it is. `ladderNames` is called only for a product with an entry, which has two or more ladder ids, so an ordinary Issue grant POST reads no names. The Issue grant handler (`:862`) builds its own resolver, so a submission whose organization holds a bundle reads the ladders' names once. A name the resolver cannot read leaves `SupersededLadders` empty for that product, and its option shows Copy W, which stays true.
|
||||
|
||||
**The sentence.** When the product has an entry, the option's hint is "This grant would supersede a tier a subscription pays for on {ladders}; its billing pauses until the grant ends.", {ladders} joined as the title joins them: for the golden's organization every marked product's option reads "…pays for on Duo and Wiki; …". A product with no entry shows Copy W, unchanged. Every ladder is named, the ones the grant will hold as well as the ones it empties, because the grant's own ladders depend on the product chosen and the sentence names the subscription's tier, which is a tier of all of them. "Billing pauses" is true here (Context).
|
||||
|
||||
**The forms library.** The hint is the field's one line, and the field declares one `Hint` (`forms.Field`, `internal/forms/forms.go:546-553`). Its text must now follow the chosen option:
|
||||
|
||||
- `forms.Option` (`:474-481`) gains `Hint string`: the line an option that carries `ShowsHint` shows in place of the declared `Hint` while it is chosen. `selectOptions` (`internal/forms/render.go:1092`) copies it into `OptionView.Hint` only for an option that carries `ShowsHint`, and the HintOnChoice branch clears it with `ShowsHint` when the field is not HintOnChoice (`:988-1000`).
|
||||
- `choiceHint` (`:1109-1118`) answers the selected option's own hint when it shows one, the declared hint when another offered option shows it, and "" when none does. `FieldView` gains `HintDefault`, the declared hint, set only when an offered option carries its own.
|
||||
- `ui_form_field.html` renders `data-hint="…"` on such an option (`:113`) and `data-hint-default="…"` on the hint line when `HintDefault` is set (`:87`), both escaped as attributes.
|
||||
- `hint-on-choice.js` (`sync`, `internal/embeds/static/hint-on-choice.js:16-32`), when the chosen option shows the hint, sets the line's `textContent` to the option's `data-hint`, or to the line's `data-hint-default` when the option has none and the line carries one. It writes text, never markup, so the strict CSP and escaping hold.
|
||||
|
||||
A form none of whose options carries its own hint renders exactly as today, so `rich.html`, `rich-history-search.html` and `suspended-subscription.html`, whose subscriptions are on one ladder, keep their markup. Rejected: putting the text in `data-shows-hint` as its value on every marked option, which changes those three goldens for no reader; and the rule-check route's warning slot (`hx-get` on change, `operator_enrollment_forms.go:230-235`), which is the rule-less warning's live region on five surfaces and would make a second line.
|
||||
|
||||
### D4. The paid tier's card on an empty ladder names the ladder the grant holds
|
||||
|
||||
**When.** On a ladder where a subscription pays for a tier (`paying` set), the ladder holds no position (`held.ProductID == ""`), and a grant holds another ladder of the same product. `grantHeldLadder(row billing.ListSubscriptionRungsByOrgRow, rows []billing.ListSubscriptionRungsByOrgRow, held map[string]entitlements.ListLadderPositionsByPoolRow, ladders []billing.PlanLadder) (ladder, granted string, ok bool)`, reading no row, walks the page's ladders in their order (`ListPlanLadders`, `member_plans.go:343-346`) and answers the first that `rows` names for the row's subscription and product, other than the row's own, whose held position a grant holds, with that ladder's name and the held product's name. "A grant holds" is `GrantID` set and `DefaultSourced` false, the test `payingFrom` makes for `GrantHolds` (`plan_offer.go:194`); one unexported function, `grantHolds(pos)`, states it for both. The loader keeps the rows `ListSubscriptionRungsByOrg` returned (`payingOn`, `:91-107`), so the page reads nothing more; `plansBuild` keeps the ladders it already read.
|
||||
|
||||
**What it draws.** `payingCard` gains `grantLadder string`. `buildLadder` sets `facts.Paid` with it when the condition holds (beside the grant-held case at `:407-411`), so `facts.paidCard` finds the card and `buildTier` draws it as the paid card, a product no longer public included (`:445-461`). `setPaidUnderGrant` (`member_plans_paid_under_grant.go:60-107`), for a card with `grantLadder`, sets the price text, the renewal, the suspension and `CollectionPaused` as it does now, keeps its two early returns (Stripe's own `paused` status, a period not known yet), sets `TierViewModel.GrantedLadder`, sets `MovesTo` and `MovesOn` only when `cancelDate()` is empty and under the existing downgrade test, and returns before `LadderID` and every control. No control is drawn and Pay invoice is not offered (`PaysInvoice`, `member_plans.go:234-236`, needs `LadderID`); the controls stay on the card of the ladder the grant holds. `buildLadder` takes the heading's cancellation badge from the paying row only for a ladder the grant holds (`:423-425`), so the empty ladder's heading reads as today.
|
||||
|
||||
**The line.** The template's three paused branches (`internal/embeds/templates/partials/member_plans.html:87-92`) insert ` on {{ .GrantedLadder }}` right after "is granted" when it is set, and one branch after them draws "Billing continues while {{ .GrantedName }} is granted on {{ .GrantedLadder }}" for a card with `GrantedLadder` whose pause is not confirmed:
|
||||
|
||||
- pause confirmed, nothing scheduled or a cancellation scheduled: `Paused while Pro is granted on Wiki`;
|
||||
- pause confirmed, payment due: `Paused while Pro is granted on Wiki, payment due`;
|
||||
- pause confirmed, a downgrade scheduled before the grant: `Paused while Pro is granted on Wiki, moves to Basic on Jan 15, 2099`; the plan moves to Basic on every card of the plan, Basic not being a Duo tier;
|
||||
- pause not confirmed, any live status: `Billing continues while Pro is granted on Wiki`.
|
||||
|
||||
Stripe's own `paused` status reads the existing `$10.00/month, paused` and `Entitlements are suspended.`, as on the held ladder's card, and a period not known yet the existing `Billing details are still being processed.`. The confirmed lines read the mirror, never the rule, as on the held ladder (`CollectionPaused`, `:85`). The card carries no grant end date, as the paid card under a grant of another tier carries none: the granted tier's current card on Wiki states it.
|
||||
|
||||
**Why "Billing continues" for an unconfirmed pause.** The held ladder's card reads the existing price lines then, because Pro's card beside it reads "Granted" and the grant is in view. On Duo nothing else names the grant, and the price line alone would read as a plan that renews and delivers. "Billing continues" is the operator's word for the same state (Copy O3 of the archived change).
|
||||
|
||||
**Which grant.** The first ladder in the page's order whose position a grant holds. A product on two ladders, the only shape the golden and Checkout reach, has one other ladder. A product on three or more ladders, two of them held by grants and one empty, names the first of the two; the line stays true, since that grant holds that ladder, and the other ladder's own card names its grant.
|
||||
|
||||
### D5. The documents
|
||||
|
||||
- `docs/models/plan-ladders-transitions.md`, invariant 7 (`:67`), the whole-bundle rule: after "inside one `core.confer` call", the rule for a subscription's product of several ladders under a grant on some of them: its rungs on the others end too and those ladders stay empty until the grant ends, because the reconcile leaves the product alone whole while the grant holds one of its ladders (invariant 20) and the default is not restored while the pool holds the grant (invariant 11); the subscription's collection is paused as for a product of one ladder, since the product it pays for is left alone and a grant holds the ladder that decides it (invariant 20); the act that ends the grant delivers the product on every one of its ladders again (invariant 20); the grant form, the organization page, the subscriptions list and the Products page name every one of those ladders. It names the two rejected alternatives with their issues (#88, #154).
|
||||
- `docs/stripe.md:216`, "A grant over a paid plan": after the titles, that a grant over one ladder of a product that is a tier of several ends the tier on every one, that the console pauses the subscription's collection as for one ladder because the subscription delivers nothing anywhere, the title's added clause, and the paid tier's line on each other ladder with no control there.
|
||||
- `docs/plan-management.md:157` (step 3) and `:164`: the warning names every ladder of a superseded bundle; the title's added clause; the paid tier's line on the other ladders.
|
||||
|
||||
`CONTRIBUTING.md`, "Docs move with the code", requires these.
|
||||
|
||||
### Rejected
|
||||
|
||||
- **The operator confirms the ladders a grant empties.** A confirmation is a block on an act a revocation undoes; the console warns instead, as it does when a product with no entitlement set is sold, and the hint, the title and the card name the ladders.
|
||||
- **Deliver the rest of the product.** Keeping the subscription's tiers on the ladders the grant is not on means conferring part of a product, which changes the whole-product conferral model that `core.confer`, `DisplacedProducts`, the delivery step and Resume all rest on; #88 records the same whole-bundle end for Checkout and the plan switch, and is that question's ground.
|
||||
- **Restore the default on each emptied ladder.** The floor runs only for a pool that holds nothing (invariant 11); a floor per ladder is #154's question.
|
||||
|
||||
## Copy
|
||||
|
||||
The UI copy rules apply (`AGENTS.md`, "UI copy"; `openspec/specs/ui-vocabulary/spec.md`, "Copy earns its place"): no new hint, notice or caption; each string replaces a line that is there today. Angle brackets mark values filled at run time.
|
||||
|
||||
| # | Where | Today | Text | Example | Decided by | Status |
|
||||
|---|---|---|---|---|---|---|
|
||||
| G1 | Issue grant, the Product field's hint, once a marked product whose superseded provision holds two or more ladders is chosen | `This grant would supersede a tier a subscription pays for; its billing pauses until the grant ends.` (Copy W) | `This grant would supersede a tier a subscription pays for on <ladders>; its billing pauses until the grant ends.` | `This grant would supersede a tier a subscription pays for on Duo and Wiki; its billing pauses until the grant ends.` | `supersededLadders`, `loadIssuanceProducts`, `issuanceProductOptions` | approved |
|
||||
| B0 | The Not delivering badge's title, one subscription (the Billing card of an account of one, every list row), pause recorded | `Billing paused while a grant holds Wiki.` | `Billing paused while <grant holder> <ladders>; nothing is delivered on <emptied>.` | `Billing paused while a grant holds Wiki; nothing is delivered on Duo.` | `displacedOnPage`, `groupDisplaced`, `pausedSentence` | approved |
|
||||
| B1 | The same, pause not recorded | `Billing continues while a grant holds Wiki.` | `Billing continues while <holder> <ladders>; nothing is delivered on <emptied>.` | `Billing continues while a grant holds Wiki; nothing is delivered on Duo.` | `continuingSentence` | approved |
|
||||
| B2 | The Billing card, several subscriptions, the displaced ones paused | `Billing paused on 2 of 3 subscriptions while a grant holds Wiki.` | `Billing paused on <k> of <n> subscriptions while <grant holder> <ladders>; nothing is delivered on <emptied>.` | `Billing paused on 2 of 3 subscriptions while a grant holds Wiki; nothing is delivered on Duo.` | `pausedSentence` | approved |
|
||||
| B3 | The same, none paused | `Billing continues on 1 of 2 subscriptions while a grant holds Wiki.` | `Billing continues on <m> of <n> subscriptions while <holder> <ladders>; nothing is delivered on <emptied>.` | `Billing continues on 1 of 2 subscriptions while a grant holds Wiki; nothing is delivered on Duo.` | `continuingSentence` | approved |
|
||||
| B4 | The same, some paused and some not: the continuing sentence after the paused one | `Billing paused on 1 of 3 subscriptions while a grant holds Wiki. Billing continues on 1 while another subscription holds Hosting.` | each sentence takes its own clause: `… while <holder> <ladders>; nothing is delivered on <emptied>.` | `Billing paused on 1 of 3 subscriptions while a grant holds Wiki; nothing is delivered on Duo. Billing continues on 1 while another subscription holds Hosting.` | `displacementTitle` | approved |
|
||||
| B5 | Any form whose holder phrase names several provisions | `Billing paused while grants hold Hosting and Wiki.` | `… while <grant holder> <ladders>; nothing is delivered on <emptied>.` | `Billing paused while grants hold Hosting and Wiki; nothing is delivered on Duo.` | `pausedHolder`, `displacementHolder` count | approved |
|
||||
| B6 | A paused title whose holders are not all grants (a grant on one ladder, another subscription on the other) | `Billing paused while a grant holds Wiki.` | `Billing paused while <holder> <ladders>.`, <holder> as for the continuing sentence | `Billing paused while other plans hold Duo and Wiki.` | `pausedSentence`, `displacementHolder` | to approve |
|
||||
| P0 | The Products page, the paid tier's card on a ladder no plan holds while a grant holds another ladder of the product, pause confirmed, nothing scheduled or a cancellation scheduled | `$10.00/month` (the tier's public price, no control) | `Paused while <granted> is granted on <ladder>` | `Paused while Pro is granted on Wiki` | `grantHeldLadder`, `setPaidUnderGrant`, `member_plans.html` | approved |
|
||||
| P1 | The same, payment due | `$10.00/month` | `Paused while <granted> is granted on <ladder>, payment due` | `Paused while Pro is granted on Wiki, payment due` | the same | approved |
|
||||
| P2 | The same, a downgrade scheduled before the grant | `$10.00/month` | `Paused while <granted> is granted on <ladder>, moves to <tier> on <date>` | `Paused while Pro is granted on Wiki, moves to Basic on Jan 15, 2099` | the same | approved |
|
||||
| P3 | The same, pause not confirmed, status `active`, `trialing`, `past_due` or `unpaid` | `$10.00/month` | `Billing continues while <granted> is granted on <ladder>` | `Billing continues while Pro is granted on Wiki` | the same | approved |
|
||||
| P4 | The same, status `paused` at Stripe | `$10.00/month` | the existing `<price>, paused` and `Entitlements are suspended.` | `$10.00/month, paused` | `setPaidUnderGrant`'s early return | reused |
|
||||
|
||||
<grant holder>, <holder> and <ladders> are as the title fills them today. <emptied> is joined as <ladders> is. <granted> is the product the grant holds on <ladder>, as the held ladder's paid card names it. No string has an em dash.
|
||||
|
||||
## Failure table
|
||||
|
||||
| # | State | Today | After |
|
||||
|---|---|---|---|
|
||||
| F1 | A subscription to a product of Wiki and Duo; a grant of a Wiki tier over it; pause recorded | Title "Billing paused while a grant holds Wiki."; Duo's Plus card "$10.00/month", no control; the form's hint, before the grant, Copy W | Title B0; Duo's card P0, no control; the hint G1 |
|
||||
| F2 | The same before Stripe confirms the pause, or after it failed | Title "Billing continues while a grant holds Wiki."; Duo's card the public price | B1; P3 |
|
||||
| F3 | A grant whose product is a tier of Wiki and Duo over the same subscription | One ladder named, the first in rung order | Both named as held: "Billing paused while a grant holds Duo and Wiki."; no empty ladder, so no P line |
|
||||
| F4 | A subscription to a one-ladder product | Today's strings | Unchanged; no form option carries its own hint |
|
||||
| F5 | The ladders' names cannot be read for the grant form | (no ladder named) | That product's option shows Copy W, true and less complete |
|
||||
| F6 | The shapes, items, displacements or (for the card) the default pool's positions read fails, or (for the list) the live ladder ids read fails | "Failed to load deliveries.", no badge | Unchanged; the new reads fail the same way |
|
||||
| F7 | After the grant emptied Duo, an operator grants a Duo tier | Duo held by that grant; its paid card reads "Paused while <Duo tier> is granted" with the paid card's controls (the grant-held case) | Duo now has a live position, so it is no longer emptied and the title reads as today: "Billing paused while a grant holds Wiki."; the Wiki grant's end still delivers Plus over the Duo grant. The member's card is the grant-held case, unchanged |
|
||||
| F8 | Another subscription, made in the Stripe Dashboard, holds Wiki over the Plus subscription | "Billing continues while another subscription holds Wiki."; Duo's card the public price | "Billing continues while another subscription holds Wiki; nothing is delivered on Duo."; Duo's card unchanged, since `grantHeldLadder` finds no grant |
|
||||
| F9 | Stripe holds the subscription as `paused` | No badge (`subscriptionMayBeDisplaced`); Duo's card the public price | No badge; Duo's card P4 |
|
||||
| F10 | The subscription pays for Plus and a product that still delivers | Billing continues; title names Wiki | Billing continues; title B1 names Duo too |
|
||||
| F11 | A bundle grant G holds Wiki and Duo over Plus; a subscription S2 takes Duo, ending G on both; Pro is then granted on the empty Wiki; the pause is recorded | "Billing paused while a grant holds Wiki." (one ladder, the deciding one) | "Billing paused while other plans hold Duo and Wiki."; the pause is unchanged, since `CollectionPauseWanted` reads `left[p]` alone |
|
||||
| F12 | The grant is revoked or expires | The act confers Plus on both ladders again | Unchanged: `DeliverDisplacedSubscriptionsTx` confers Plus on both ladders inside the ending act, so no badge and no P line remain |
|
||||
| F13 | After the grant emptied Duo, a plan that is not a grant (another subscription) takes Duo | Duo's paid card the public price; title names Wiki | Duo has a live position, so the title omits Duo; Duo's member card shows that plan's card as today, and `grantHeldLadder` finds no grant on it |
|
||||
| F14 | The organization type's default is restored on Duo while the grant holds Wiki | (cannot happen) | Cannot happen: the floor runs only for a pool that holds nothing on any ladder (invariant 11), and the pool holds the grant |
|
||||
|
||||
## Locks and transactions
|
||||
|
||||
No lock or transaction is added. `ListConferralShapes` joins `core.plan_ladders` in the statement the reconcile, the delivery step and Resume already run under the pool row and the shared rendezvous; a plain read takes no row lock, so the lock order in `docs/database-locks.md` is unchanged. `ListLiveLadderIDsBySubscriptionIDs` is a plain read run by the subscriptions list only, outside any lock. The organization page and the member's Products page read no more statements: the title's names ride the shapes read, its live ladders ride the Pools panel's position read, the hint's names ride the Pools panel's ladder read, and the member card rides the positions and paying reads the loader already caches. The subscriptions list reads one more statement per page, the live ladder ids, and never one per row: `TestOperatorPagesReadAFixedNumberOfTimes` measures what two more rows cost (`perRow`, 0 for the page `billing subscriptions`), so it passes unedited and no expected count in it changes.
|
||||
|
||||
## Tests
|
||||
|
||||
Each claim is pinned by a deliberate break (`docs/testing.md`, "Prove a test with deliberate breaks"); tasks list each with the text its failing test prints.
|
||||
|
||||
| # | Test | What it proves |
|
||||
|---|---|---|
|
||||
| T1 | `TestReconcile_AGrantOnOneLadderOfABundlePausesIt` (new, `internal/fulfillment`) | D1: the product left alone on both ladders, the second ladder empty, the pause requested |
|
||||
| T2 | `TestDisplacedOnPage` rows, `TestDisplacementTitle` rows | D2: emptied ladders, the `live` input, mixed holders, the clause and its agreement |
|
||||
| T3 | `TestSubscriptionsListNamesEveryLadderOfABundle` (new, database) | D2 through the real statements, the ladder name from `ListConferralShapes` |
|
||||
| T4 | `TestOrgEnrollmentTwoLadderGolden` (new, its own World), states `two-ladder-subscription` and `grant-over-two-ladder-subscription-paused` | D2, D3 on the rendered page |
|
||||
| T5 | `TestSupersededLadders` (new), `TestOrgEnrollmentIssueGrantSupersedeHint` rows | D3: which ladders, the option's hint, Copy W kept for one ladder |
|
||||
| T6 | `TestRenderHintOnChoiceShowsTheChosenOptionsOwnHint` (new, `internal/forms`) | D3: `Option.Hint`, `data-hint`, `data-hint-default`, the refused submission's text |
|
||||
| T7 | `TestGrantHeldLadder` (new), `TestPaidPlanCardOnAVacatedLadder` (new) | D4: which ladder, each line, no control |
|
||||
| T8 | `TestMemberPlansGolden`, seven states rewritten | D4 on the rendered page |
|
||||
| T9 | `TestGrantSupersedeHintWalkthrough`, a step for an option with its own hint | D3's script, where a subject exists |
|
||||
|
||||
## What is unverified
|
||||
|
||||
- **The script's text change in a browser under `make test`.** No demo state holds a subscription to a product of two ladders, so the walkthrough's new step skips with its precondition named, and the break of the script is seen by no test of the commit (tasks). The hand captures show it.
|
||||
- **How a screen reader announces a hint whose text changed.** The line stays named by the select's `aria-describedby` while shown, as today; no screen reader was run.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **The title grows a clause** for a subscription to a product of several ladders, about twenty characters, in a tooltip.
|
||||
- **Every member `.json` golden and every organization `.json` golden but `no-org` gains a field**, which makes the commit's golden diff wide; each file is read and its diff named in the tasks.
|
||||
- **A product of three or more ladders names one grant's ladder** on the empty ladder's card when two grants hold the others (D4, "Which grant").
|
||||
@@ -0,0 +1,51 @@
|
||||
## Why
|
||||
|
||||
A product can be a tier of several ladders. When an operator's grant takes one of those ladders from a subscription that pays for such a product, `core.confer` supersedes the subscription's whole provision, so the subscription's rungs on the other ladders end too (`docs/models/plan-ladders-transitions.md:67`, invariant 7). The subscription's reconcile then leaves the product alone, because a conferral places a product whole (`entitlements.DisplacedProducts`, `internal/entitlements/displacement.go:108-125`), and no default is restored on the other ladders while the pool holds the grant. Those ladders stay empty until the grant ends.
|
||||
|
||||
The subscription's billing pauses in this case. `core.subscription_displacements` returns a row only for the ladder the grant holds (`internal/db/migrations/00037_displacement_holder_rank.sql:40-69`; a supersession pair needs the successor's row on the same ladder, `00033_supersession_pairs.sql:35-43`), and `DisplacedProducts` maps the product to that row. The pause rule wants a pause when every conferring product is left alone and every displacement that leaves one alone is a grant's (`entitlements.CollectionPauseWanted`, `internal/entitlements/collection.go:20-39`), which the reconcile asks in its active branch (`internal/fulfillment/reconcile.go:173`). A subscription that pays for that product alone is paused.
|
||||
|
||||
Three places say less than that. The Issue grant form's hint names no ladder. The organization page's Not delivering badge, and the operator's subscriptions list, which reads the same function, name only the ladder the grant holds (`displacedOnPage` names one ladder per product, `internal/server/operator_enrollment.go:510`): "Billing paused while a grant holds Wiki." On the member's Products page, the paid tier's card on the empty ladder reads the tier's public price and offers no control (`internal/server/testdata/member_plans/paid-plus-under-granted-pro.html:183`, "$10.00/month"), so nothing on that ladder says why it is empty while the subscription is paid for.
|
||||
|
||||
The ruling on #271 keeps whole-product supersession and says so everywhere a person decides or looks.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **The grant form names every ladder a superseded bundle ends on.** When a chosen product would supersede a subscription provision that holds more than one ladder, the Product field's hint reads "This grant would supersede a tier a subscription pays for on Duo and Wiki; its billing pauses until the grant ends." A product that supersedes a one-ladder tier keeps today's hint. The forms library gains an option's own hint: `forms.Option.Hint`, rendered as `data-hint`, with the declared hint in `data-hint-default` on the line, and `hint-on-choice.js` sets the line's text from the chosen option.
|
||||
- **The Not delivering badge names every ladder the subscription delivers nothing on.** The title keeps its holder clause for the ladders the holder takes and adds the others: "Billing paused while a grant holds Wiki; nothing is delivered on Duo." The organization page's Billing card and the subscriptions list read the same function, so both change. `ListConferralShapes` gains each rung's ladder name, so the title can name a ladder no displacement row names.
|
||||
- **The paid tier's card on an empty ladder names the ladder the grant holds.** On a ladder the subscription pays for and no plan holds, while a grant holds another ladder of the same product, the card reads "Paused while Pro is granted on Wiki" (with ", payment due" or ", moves to Basic on {date}" as on the held ladder's card), or "Billing continues while Pro is granted on Wiki" while Stripe has not confirmed the pause. It still offers no control.
|
||||
- **The model and the Stripe documents state the rule.** `docs/models/plan-ladders-transitions.md` states it in invariant 7, beside whole-bundle supersession; `docs/stripe.md` and `docs/plan-management.md` say that billing pauses in this case and quote the new strings.
|
||||
- **A test drives a grant over one ladder of a two-ladder subscription** through the reconcile and finds the product left alone on both ladders, the other ladder empty, and the pause requested.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **The operator confirming the ladders a grant empties**, **delivering the rest of the product**, and **restoring the default on each emptied ladder** (design, Rejected).
|
||||
- **A ladder a later plan holds after the grant emptied it.** The operator's title no longer names that ladder, because the emptied ladders are those with no live position when the page renders (design D2). The member's card there is the paid card of a ladder a grant holds when a grant holds it, as today; when another kind of plan holds it, the member's card shows that plan's card as today.
|
||||
- **A ladder emptied by another subscription's hold** (a pair made in the Stripe Dashboard). The operator's title names it with the same clause; the member's card on it keeps today's public price, because no grant holds the ladder that decides it.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
None.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `plan-enrollment-administration`: "Advisory warning when a grant would supersede a subscription-sourced position" (the hint names the ladders of a superseded bundle) and "The billing card says when a subscription delivers nothing while another plan holds its ladder" (the title names every ladder the subscription delivers nothing on).
|
||||
- `operator-billing-views`: "The subscriptions list marks a subscription that delivers nothing while another plan holds its ladder" (the same title).
|
||||
- `member-product-discovery`: "A paid plan whose ladder a grant holds shows its pause on its own card" (the card on an empty ladder names the ladder the grant holds).
|
||||
- `stripe-subscription-management`: "A grant's hold pauses the subscription's collection at Stripe" (the rule's first sentence says what the code decides for a product of several ladders, with a scenario).
|
||||
- `form-library`: "A select's hint can wait for the option that shows it" (an option's own hint).
|
||||
|
||||
## Impact
|
||||
|
||||
- **Code:** `internal/server/operator_enrollment.go` (`displacedOnPage` and its `live` input, `groupDisplaced`, the title sentences, `loadIssuanceProducts` and its two reads), `internal/server/operator_billing.go` (the list's live read), `internal/server/operator_enrollment_forms.go` (the hint sentence and the options), `internal/entitlements/displacement.go` (`Rung.LadderName`), `internal/entitlements/queries/pool_provision_ladders.sql` (`ListConferralShapes` gains the ladder name), `internal/entitlements/queries/pool_provision_transitions.sql` (`ListLiveLadderIDsBySubscriptionIDs`), `internal/forms/forms.go` and `render.go` (`Option.Hint`), `internal/embeds/templates/partials/ui_form_field.html`, `internal/embeds/static/hint-on-choice.js`, `internal/server/member_plans.go`, `member_plans_paid_under_grant.go`, `plan_offer.go` and `internal/embeds/templates/partials/member_plans.html`.
|
||||
- **Schema, routes, Stripe calls:** none. `ListConferralShapes` joins `core.plan_ladders`, and a new read, `ListLiveLadderIDsBySubscriptionIDs`, gives the subscriptions list its live ladders; the organization page, the member's Products page and the reconcile read no more statements, and the list reads one more per page, never one per row.
|
||||
- **Behaviour a deployment sees:** for a subscription to a product of several ladders under a grant on some of them, the grant form, the organization page, the subscriptions list and the member's Products page name the other ladders. Nothing else changes; billing already pauses.
|
||||
- **Tests and goldens:** a reconcile test, rows in the title, page and card tests, new organization-page golden states, the member goldens of seven states, every member `.json` golden (a new field) and every organization `.json` golden but `no-org` (a new field).
|
||||
- **Docs:** `docs/models/plan-ladders-transitions.md`, `docs/stripe.md`, `docs/plan-management.md`.
|
||||
|
||||
## Related issues
|
||||
|
||||
- **#271** is this change; it closes with it.
|
||||
- **#88** records whole-bundle supersession by Checkout and the plan switch, the ground of the rejected "deliver the rest of the product".
|
||||
- **#154** asks whether the default belongs per ladder, the ground of the rejected "restore the default on each emptied ladder".
|
||||
@@ -0,0 +1,39 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: A select's hint can wait for the option that shows it
|
||||
|
||||
A select field of a Stacked-family form MAY declare `HintOnChoice` with its `Hint`, and each option the select offers MAY carry `ShowsHint`. The field part SHALL render the hint only when an option the select offers carries `ShowsHint`, and SHALL render it with the `hidden` attribute unless the selected option carries `ShowsHint`, in every mode: at rest, a select that opens on its placeholder renders the hint hidden, and a refused submission renders the hint shown when the submitted option carries `ShowsHint` and hidden otherwise. The control's `aria-describedby` SHALL name the hint only while it shows. The select SHALL name the hint's id in `data-hint-toggle`, and each option that shows it SHALL carry `data-shows-hint`. An option that carries `ShowsHint` MAY carry its own `Hint`, the text the line shows in place of the field's declared `Hint` while that option is chosen; an option that does not carry `ShowsHint` SHALL render no hint of its own. Such an option SHALL carry its text in `data-hint`, and when any option the select offers carries its own hint, the line SHALL carry the declared `Hint` in `data-hint-default`; a select none of whose options carries its own hint SHALL render neither attribute. An option's own hint is not held to the registry's 100-character hint cap, because the ladder names it carries are the fact and vary in length. The server SHALL render the line with the chosen option's own hint when it has one, and with the declared `Hint` otherwise. One external script, loaded with `defer` by the shell of every surface that renders such a field, SHALL show and hide the hint and add and remove its id in the select's `aria-describedby` when the choice changes, and, when the chosen option shows the hint, SHALL set the line's text to the option's `data-hint`, or to the line's `data-hint-default` when the option carries none and the line carries one, as text and never as markup, on load and after every htmx swap; without the script the hint SHALL stay as the server rendered it. The hint is the field's one declared line under its control and is checked as every declared hint is: the invariants SHALL refuse `HintOnChoice` on a control other than a select, in a family other than Stacked, without a `Hint`, or with a `Notice`.
|
||||
|
||||
#### Scenario: The hint waits for a choice
|
||||
|
||||
- **WHEN** a person opens a form whose `HintOnChoice` select offers an option that carries `ShowsHint`, and no option is chosen
|
||||
- **THEN** the hint renders with `hidden`, the select names it in `data-hint-toggle`, and the select's `aria-describedby` does not name it
|
||||
|
||||
#### Scenario: Choosing the option shows the hint
|
||||
|
||||
- **WHEN** the person chooses an option that carries `ShowsHint`
|
||||
- **THEN** the hint shows and the select's `aria-describedby` names it
|
||||
- **AND** choosing an option that does not carry it hides the hint and removes its id from `aria-describedby`
|
||||
|
||||
#### Scenario: A refused submission keeps the hint of its choice
|
||||
|
||||
- **WHEN** a submission whose chosen option carries `ShowsHint` is refused
|
||||
- **THEN** the 422 body renders the hint shown and named by the select's `aria-describedby`
|
||||
|
||||
#### Scenario: No option shows it, so no line renders
|
||||
|
||||
- **WHEN** the select offers no option that carries `ShowsHint`
|
||||
- **THEN** the field renders no hint line and no `data-hint-toggle`
|
||||
|
||||
#### Scenario: An option's own hint replaces the declared one while it is chosen
|
||||
|
||||
- **WHEN** a `HintOnChoice` select declares the hint "B is special." and offers option B, which carries `ShowsHint`, and option C, which carries `ShowsHint` and its own hint "C is more special."
|
||||
- **THEN** option C carries `data-hint="C is more special."`, option B carries no `data-hint`, and the hint line carries `data-hint-default="B is special."`
|
||||
- **AND** a refused submission that chose C renders the line shown, reading "C is more special.", and one that chose B renders it reading "B is special."
|
||||
- **AND** choosing B after C sets the line's text back to "B is special."
|
||||
|
||||
#### Scenario: No option carries its own hint
|
||||
|
||||
- **WHEN** a `HintOnChoice` select offers options that carry `ShowsHint` and none carries its own hint
|
||||
- **THEN** no option carries `data-hint` and the line carries no `data-hint-default`
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: A paid plan whose ladder a grant holds shows its pause on its own card
|
||||
|
||||
On the Products page, on a ladder a grant holds while a live subscription of the organization pays for a tier of it (`member-offer`, "One decision says which move a member is offered on a ladder"), the card of the tier the subscription pays for SHALL carry the subscription's state and its controls, whichever card it is, the current card included when the grant is of that tier, and SHALL be drawn even when its product is no longer a public plan. Its price line SHALL read, by the subscription's state as `core.subscriptions` records it:
|
||||
|
||||
- a `pause_collection` behavior recorded (whoever set the pause), status `active` or `trialing`, nothing scheduled: "Paused while {granted} is granted", with Resume plan when the decision offers it and then Cancel plan;
|
||||
- a `pause_collection` behavior recorded and a cancellation scheduled: "Paused while {granted} is granted", with Keep plan, and the ladder's existing "cancels on {date}" badge;
|
||||
- a `pause_collection` behavior recorded and a downgrade scheduled before the grant: "Paused while {granted} is granted, moves to {tier} on {date}", with Keep plan and then Cancel plan, Pay invoice first when the payment is due;
|
||||
- a `pause_collection` behavior recorded, status `past_due` or `unpaid`: "Paused while {granted} is granted, payment due", with Pay invoice and then Cancel plan or Keep plan, and no "Entitlements are suspended" line;
|
||||
- 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 "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 the ladder's heading SHALL carry no cancellation badge for the subscription. A grant on one ladder of a product that is a tier of several ladders ends the subscription's tier on every one of them, so when a grant holds another ladder of the product the subscription pays for, that card SHALL be drawn even when its product is no longer a public plan, and its price line SHALL name the grant's product and that ladder, the first such ladder in the page's ladder order, by the subscription's state as `core.subscriptions` records it:
|
||||
|
||||
- a `pause_collection` behavior recorded, status `active` or `trialing`, nothing scheduled or a cancellation scheduled: "Paused while {granted} is granted on {ladder}";
|
||||
- a `pause_collection` behavior recorded and a downgrade scheduled before the grant: "Paused while {granted} is granted on {ladder}, moves to {tier} on {date}";
|
||||
- a `pause_collection` behavior recorded, status `past_due` or `unpaid`, no downgrade scheduled: "Paused while {granted} is granted on {ladder}, payment due";
|
||||
- no `pause_collection` behavior recorded, status `active`, `trialing`, `past_due` or `unpaid`: "Billing continues while {granted} is granted on {ladder}";
|
||||
- status `paused`: the existing "{price}, paused" and "Entitlements are suspended.";
|
||||
- no period end recorded: the existing "Billing details are still being processed.".
|
||||
|
||||
{granted} SHALL be the name of the product the grant holds on {ladder}, and the line SHALL carry no grant end date. When no grant holds another ladder of the product, the card SHALL draw the tier's price, as every card does, and none of these lines. The page SHALL read the paying subscriptions in one statement per request, and SHALL read nothing more to find the ladder a grant holds.
|
||||
|
||||
#### Scenario: The paid plan's card while the grant holds
|
||||
|
||||
- **WHEN** a member opens the Products page of an organization whose subscription to Plus is paused for a Pro grant on the same ladder
|
||||
- **THEN** Plus's card reads "Paused while Pro is granted" with Cancel plan, and Pro's card, the current card, and Max's card each offer the switch
|
||||
|
||||
#### Scenario: A payment due from before the grant
|
||||
|
||||
- **WHEN** the same subscription is `past_due` with an invoice from before the grant
|
||||
- **THEN** Plus's card reads "Paused while Pro is granted, payment due" with Pay invoice and then Cancel plan, and no "Entitlements are suspended" line
|
||||
|
||||
#### Scenario: The cancellation's confirmation
|
||||
|
||||
- **WHEN** the member chooses Cancel plan on Plus's card
|
||||
- **THEN** the confirmation reads "Plus ends on {date}; Pro stays granted." with the subscription's period end as the date
|
||||
|
||||
#### Scenario: A pause not yet confirmed
|
||||
|
||||
- **WHEN** the grant was issued and Stripe has not yet confirmed the pause
|
||||
- **THEN** Plus's card reads "{price}, renews {date}" with Cancel plan
|
||||
|
||||
#### Scenario: A subscription Stripe holds as paused
|
||||
|
||||
- **WHEN** the subscription to Plus is `paused` at Stripe while the Pro grant holds the ladder
|
||||
- **THEN** Plus's card reads "{price}, paused" and "Entitlements are suspended.", and draws no control
|
||||
|
||||
#### Scenario: The cancellation's confirmation when the payment is due
|
||||
|
||||
- **WHEN** the paused Plus subscription is `past_due` and the member chooses Cancel plan on Plus's card
|
||||
- **THEN** the confirmation reads "Plus ends on {date}; Pro stays granted. The unpaid invoice stays due."
|
||||
|
||||
#### Scenario: A cancelled paused plan
|
||||
|
||||
- **WHEN** the paused Plus subscription is set to cancel at its period end
|
||||
- **THEN** Plus's card reads "Paused while Pro is granted" with Keep plan, and the ladder carries its "cancels on {date}" badge
|
||||
|
||||
#### Scenario: A downgrade scheduled before the grant
|
||||
|
||||
- **WHEN** the Plus subscription scheduled a downgrade to Basic before the Pro grant, and the pause is confirmed
|
||||
- **THEN** Plus's card reads "Paused while Pro is granted, moves to Basic on {date}" with Keep plan and then Cancel plan
|
||||
|
||||
#### Scenario: A grant of the subscription's own tier
|
||||
|
||||
- **WHEN** a grant of Plus holds the ladder over the paused Plus subscription, and the member chooses Cancel plan on Plus's card, the current card
|
||||
- **THEN** the card reads "Paused while Plus is granted", and the confirmation reads "Your subscription ends on {date}; Plus stays granted."
|
||||
|
||||
#### Scenario: A grant of the subscription's own tier with an end date
|
||||
|
||||
- **WHEN** a grant of Plus whose end date is Feb 20, 2099 holds the ladder over the paused Plus subscription
|
||||
- **THEN** Plus's card, the current card, reads "Paused while Plus is granted until Feb 20, 2099"
|
||||
|
||||
#### Scenario: A grant of a higher tier with an end date
|
||||
|
||||
- **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
|
||||
- **THEN** Pro's card reads "Paused while Basic is granted" with Resume plan and then Cancel plan, Plus's card draws no control, and Max's card offers "Upgrade"
|
||||
- **AND** Resume plan opens the confirmation "Resume Pro?" with "Your subscription replaces the grant. Nothing is charged now; then {price}."
|
||||
|
||||
#### Scenario: The paid plan's card on the ladder the grant does not hold
|
||||
|
||||
- **WHEN** a member opens the Products page of an organization whose subscription to Plus, a tier of the Wiki and Duo ladders, is paused for a Pro grant on Wiki, and nothing holds Duo
|
||||
- **THEN** Plus's card on Duo reads "Paused while Pro is granted on Wiki" and draws no control, Duo's heading carries no badge, and Plus's card on Wiki reads "Paused while Pro is granted" with Cancel plan
|
||||
|
||||
#### Scenario: The same before Stripe confirms the pause
|
||||
|
||||
- **WHEN** the Pro grant was issued and Stripe has not yet confirmed the pause
|
||||
- **THEN** Plus's card on Duo reads "Billing continues while Pro is granted on Wiki" and draws no control
|
||||
|
||||
#### Scenario: The same with a payment due
|
||||
|
||||
- **WHEN** the paused Plus subscription is `past_due`
|
||||
- **THEN** Plus's card on Duo reads "Paused while Pro is granted on Wiki, payment due" and offers no Pay invoice, which Plus's card on Wiki offers
|
||||
|
||||
#### Scenario: The same with a cancellation scheduled
|
||||
|
||||
- **WHEN** the paused Plus subscription is set to cancel at its period end
|
||||
- **THEN** Plus's card on Duo reads "Paused while Pro is granted on Wiki", Duo's heading carries no badge, and Wiki's heading carries its "cancels on {date}" badge
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: The subscriptions list marks a subscription that delivers nothing while another plan holds its ladder
|
||||
|
||||
In the operator's subscriptions list (`/operator/billing/subscriptions`), the row of a subscription whose status is `active`, `trialing`, `past_due` or `unpaid` and that is displaced on a ladder another plan holds SHALL carry the "Not delivering" badge, in the warning tone, through the shared status-badge part, in its Status cell after the state badge and, when it renders, the Canceling badge. The row SHALL be decided by the rule the organization composite's billing card applies (`plan-enrollment-administration`, "The billing card says when a subscription delivers nothing while another plan holds its ladder"): `core.subscription_displacements` for the subscription and its organization's active default pool, applied through the reconcile's choice over the subscription's current items, so the list and the card never disagree about a subscription. The badge's title SHALL be worded as the card words it for an account of one subscription, because a row is one subscription: "Billing paused while a grant holds {ladders}." when the row records a `pause_collection` behavior and every plan that holds the ladders the reconcile's choice picked is a grant ("grants hold" when several grant provisions hold them; "other plans hold" when a ladder the title names is held by a plan that is not a grant), and "Billing continues while {holder} {ladders}." otherwise. When a product the subscription pays for is left alone and is a tier of ladders on which the subscription has no displacement, the title SHALL name those ladders as the card does, with "; nothing is delivered on {emptied}" before its period, as the card does and under the same definition of an emptied ladder, a ladder of that product on which no live position exists when the page renders: "Billing paused while a grant holds Wiki; nothing is delivered on Duo." The list SHALL read the pause from the page's own subscription read. A canceled, `paused` or `incomplete` subscription, a subscription no other plan displaced, and a ladder held only by the organization type's default SHALL produce no badge.
|
||||
|
||||
The list SHALL read the displacements, the current items, their ladders and the live ladders of the subscriptions' organizations once for the page, never once per row; the names of the ladders a title names SHALL come from those reads. A failed read of any of them SHALL be logged, SHALL leave every row without the badge, and SHALL render "Failed to load deliveries." once, above the table, in the danger style, while the rows the page read still render with their state, period and sync status.
|
||||
|
||||
#### Scenario: A grant over a subscription marks its row
|
||||
|
||||
- **WHEN** an operator views the subscriptions list, and an operator's grant superseded a listed subscription's position on ladder Hosting and still delivers, and Stripe has not confirmed the pause
|
||||
- **THEN** that row's Status cell reads its state badge and the "Not delivering" badge titled "Billing continues while a grant holds Hosting."
|
||||
|
||||
#### Scenario: A confirmed pause marks its row as paused
|
||||
|
||||
- **WHEN** the same subscription's row records the console's pause
|
||||
- **THEN** the badge's title reads "Billing paused while a grant holds Hosting."
|
||||
|
||||
#### Scenario: Two grants hold a paused row's ladders
|
||||
|
||||
- **WHEN** a listed subscription pays for a tier of Hosting and a tier of Wiki (a shape only the Stripe Dashboard makes, since Checkout sells one line item), one operator's grant superseded it on Hosting and another on Wiki, and the row records the console's pause
|
||||
- **THEN** the badge's title reads "Billing paused while grants hold Hosting and Wiki."
|
||||
|
||||
#### Scenario: Two rows paused under their own grants
|
||||
|
||||
- **WHEN** two listed subscriptions with one item each are displaced, one by a grant on Hosting and the other by another grant on Wiki, and both rows record the console's pause
|
||||
- **THEN** the first row's badge title reads "Billing paused while a grant holds Hosting." and the second's reads "Billing paused while a grant holds Wiki."
|
||||
|
||||
#### Scenario: A delivering subscription has no badge
|
||||
|
||||
- **WHEN** a listed subscription delivers on every ladder its current items name
|
||||
- **THEN** its row carries no "Not delivering" badge
|
||||
|
||||
#### Scenario: A canceled subscription has no badge
|
||||
|
||||
- **WHEN** a listed subscription that an operator's grant superseded has been canceled
|
||||
- **THEN** its row carries no "Not delivering" badge
|
||||
|
||||
#### Scenario: The list and the card agree
|
||||
|
||||
- **WHEN** an organization has one subscription, displaced by an operator's grant, paused or not
|
||||
- **THEN** the badge's title on its row in the subscriptions list equals the title of the badge on the organization's billing card
|
||||
|
||||
#### Scenario: The page reads once
|
||||
|
||||
- **WHEN** two more displaced subscriptions are added to the subscriptions a page of the list shows
|
||||
- **THEN** the page runs no more statements than before
|
||||
|
||||
#### Scenario: A failed read says so above the table
|
||||
|
||||
- **WHEN** the read of the displacements for the page fails
|
||||
- **THEN** the list renders its rows without the badge and "Failed to load deliveries." once above the table
|
||||
|
||||
#### Scenario: A row whose bundle a grant holds on one ladder
|
||||
|
||||
- **WHEN** a listed subscription pays for Plus, a tier of the Wiki and Duo ladders, an operator's grant of Pro, a tier of Wiki alone, superseded it, and the row records the console's pause
|
||||
- **THEN** the badge's title reads "Billing paused while a grant holds Wiki; nothing is delivered on Duo."
|
||||
- **AND** it equals the title on the organization's billing card
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Advisory warning when a grant would supersede a subscription-sourced position
|
||||
|
||||
When an operator's grant submission would cause `confer` to supersede a position currently held by a subscription-sourced provision, active or suspended, the Product select SHALL mark each such product with ⚠, and once the operator chooses a marked product, and not before, the Product field SHALL show, as its one hint line and not as an alert, an advisory warning stating that the subscription's billing pauses until the grant ends: "This grant would supersede a tier a subscription pays for; its billing pauses until the grant ends." When a provision the chosen product would supersede holds more than one ladder, because the product the subscription pays for is a tier of several ladders, the hint SHALL name every ladder the provisions it would supersede hold, by their display names sorted by `strings.Compare` and joined as "A and B" or "A, B and C": "This grant would supersede a tier a subscription pays for on {ladders}; its billing pauses until the grant ends." `confer` ends every rung of a provision it supersedes, so the ladders the grant's product is not a tier of stay empty until the grant ends, and the subscription's billing pauses as for a tier of one ladder (`stripe-subscription-management`, "A grant's hold pauses the subscription's collection at Stripe"). The ladders' names SHALL be read with the page's other ladder names, and a name the page cannot read SHALL leave that product's hint as the sentence without ladders. A refused submission whose chosen product is marked SHALL render the hint shown, in the form the chosen product takes (`stripe-subscription-management`, "A grant's hold pauses the subscription's collection at Stripe", states when the pause applies, and "Subscription reconcile computes a genuine diff, grouped by product" when the subscription delivers again). The warning SHALL NOT send the operator to another view to change the subscription, because the operator's billing views only read. A suspended position counts because `confer` supersedes a suspended incumbent as it supersedes an active one; a past-due subscription's renewals pause as an active one's do, and a paused one is not invoiced. This is a visibility measure only: the system SHALL NOT block the submission, and SHALL NOT ask the operator to confirm the ladders the grant empties. The grant's own transaction SHALL NOT call Stripe; it SHALL request the subscription's reconcile through the transactional outbox, which pauses the subscription's collection, and SHALL NOT cancel the subscription.
|
||||
|
||||
#### Scenario: Operator is warned before superseding a subscription-held position
|
||||
|
||||
- **WHEN** an operator submits a grant for a product that would supersede a ladder position currently delivered by a subscription-sourced provision
|
||||
- **THEN** the UI SHALL display the advisory warning that the subscription's billing pauses until the grant ends
|
||||
- **AND** the submission SHALL proceed to create the grant and invoke `confer` if the operator confirms, and its transaction SHALL write a `reconcile_subscription` outbox entry for the subscription
|
||||
|
||||
#### Scenario: Operator is warned about a suspended subscription's position
|
||||
|
||||
- **WHEN** an operator opens Issue grant for an organization whose subscription on a ladder is `past_due`, so its position there is suspended
|
||||
- **THEN** the form SHALL mark the products that are tiers of that ladder as superseding a subscription and SHALL show the advisory warning once one of them is chosen
|
||||
|
||||
#### Scenario: The warning names no view to change the subscription
|
||||
|
||||
- **WHEN** an operator opens Issue grant and chooses a product marked ⚠
|
||||
- **THEN** the Product field's hint reads "This grant would supersede a tier a subscription pays for; its billing pauses until the grant ends."
|
||||
- **AND** it names no billing view
|
||||
|
||||
#### Scenario: No warning before a marked product is chosen
|
||||
|
||||
- **WHEN** an operator opens Issue grant on an organization whose Product select offers a product marked ⚠, and has chosen no product or an unmarked one
|
||||
- **THEN** the form shows no supersession warning, and the ⚠ on each marked option is the only sign
|
||||
|
||||
#### Scenario: The warning names every ladder of a subscription's bundle
|
||||
|
||||
- **WHEN** an organization's subscription pays for Plus, a tier of the Wiki and Duo ladders that it holds on both, and an operator opening Issue grant chooses Pro, a tier of Wiki alone
|
||||
- **THEN** the Product field's hint reads "This grant would supersede a tier a subscription pays for on Duo and Wiki; its billing pauses until the grant ends."
|
||||
- **AND** choosing Duo Max, a tier of Duo alone, shows the same sentence
|
||||
|
||||
#### Scenario: A one-ladder tier keeps the sentence without ladders
|
||||
|
||||
- **WHEN** the same form offers a product that would supersede only a subscription provision that holds one ladder, and the operator chooses it after choosing Pro
|
||||
- **THEN** the hint reads "This grant would supersede a tier a subscription pays for; its billing pauses until the grant ends."
|
||||
|
||||
### Requirement: The billing card says when a subscription delivers nothing while another plan holds its ladder
|
||||
|
||||
On the organization composite, the billing card's Subscriptions row SHALL carry a "Not delivering" badge, in the warning tone, through the shared status-badge part, directly after the row's state badge and before the count, when at least one subscription of the billing account whose status is `active`, `trialing`, `past_due` or `unpaid` is displaced on a ladder that another plan holds, as `core.subscription_displacements` states it for the subscription and the organization's default pool, for a product among the subscription's current items, through the same choice the reconcile makes (`stripe-subscription-management`, "Subscription reconcile computes a genuine diff, grouped by product"). The card SHALL NOT restate the rule, and a displacement on a ladder that no current item of the subscription names SHALL produce no badge. A displaced subscription is **paused** for this title when `core.subscriptions` records a `pause_collection` behavior for it and every plan that holds the ladders the reconcile's choice picked (the displacements that leave its products alone) is a grant; the card SHALL read the record, never the rule, so a pause Stripe has not confirmed reads as billing. The badge's title SHALL name the ladders, by their display names, and the kind of plan that holds them, and SHALL name no product, as the rest of the card names none:
|
||||
|
||||
- for an account with one subscription: "Billing paused while {grant holder} {ladders}." when it is paused, and "Billing continues while {holder} {ladders}." otherwise;
|
||||
- for an account with n subscriptions, n greater than 1 and the row's count, of which k are displaced and paused and m are displaced and not paused: "Billing paused on {k} of {n} subscriptions while {grant holder} {ladders}." when m is 0; "Billing continues on {m} of {n} subscriptions while {holder} {ladders}." when k is 0; and "Billing paused on {k} of {n} subscriptions while {grant holder} {ladders}. Billing continues on {m} while {holder} {ladders}." otherwise, each sentence naming the ladders and holders of its own subscriptions.
|
||||
|
||||
{ladders} SHALL be the distinct displaced ladders' names sorted by `strings.Compare`, joined as "A", "A and B", or "A, B and C". {holder} SHALL be "a grant holds", "another subscription holds" or "another plan holds" when one provision holds every ladder the sentence names, by that provision's source (a grant, a subscription other than the displaced one, or any other source), and "other plans hold" when several provisions hold them. {grant holder} SHALL be "a grant holds" when one grant provision holds every ladder the paused sentence names and "grants hold" when several grant provisions hold them. A subscription that is canceled, `paused` or `incomplete`, a subscription no other plan displaced, and a ladder held only by the organization type's default SHALL produce no badge.
|
||||
|
||||
A product the reconcile's choice leaves alone delivers nothing on any of its ladders, so the title SHALL name every ladder of that product: a ladder on which the subscription has a displacement SHALL be among {ladders}, with that displacement's holder, and every other ladder of the product on which no live position exists when the page renders SHALL be among {emptied}. A sentence whose subscriptions have an emptied ladder SHALL end, before its period, with "; nothing is delivered on {emptied}": {emptied} SHALL be the distinct emptied ladders of the sentence's subscriptions, less those the sentence names in {ladders}, sorted and joined as {ladders} are, and in the composite form the clause SHALL attach to the sentence of the subscription whose ladders are emptied. A ladder of the product that something else holds SHALL be named in neither clause. The live positions of the ladders SHALL be those of the organization's active default pool that the page's Pools panel reads, with no further statement. A sentence whose holders are not all grants SHALL word its holders as the continuing sentence does, so a paused subscription whose ladders are held by a grant and by another subscription reads "Billing paused while other plans hold Duo and Wiki." For example: "Billing paused while a grant holds Wiki; nothing is delivered on Duo." A ladder's name SHALL come from the conferral shapes the card already reads. A sentence whose subscriptions have no emptied ladder SHALL read as above. The row SHALL carry no other text for the condition. A failed read of the displacements, the subscription's items, their ladders or the pool's positions SHALL be logged, SHALL show "Failed to load deliveries." under the row's value line in the danger style, and SHALL render no badge, so the row never reads as if every subscription delivered. The card SHALL change nothing at Stripe and SHALL offer no control for the condition.
|
||||
|
||||
#### Scenario: A grant over the only subscription
|
||||
|
||||
- **WHEN** an operator views the organization page of an organization whose one subscription, on ladder Hosting, was superseded by an operator's grant that still delivers, and Stripe has not confirmed the pause
|
||||
- **THEN** the Subscriptions row reads its state badge, the "Not delivering" badge titled "Billing continues while a grant holds Hosting.", "1 subscription" as a link and its period clause
|
||||
|
||||
#### Scenario: A confirmed pause on the only subscription
|
||||
|
||||
- **WHEN** the same subscription's row records the console's pause
|
||||
- **THEN** the badge's title reads "Billing paused while a grant holds Hosting."
|
||||
|
||||
#### Scenario: Several subscriptions, all paused
|
||||
|
||||
- **WHEN** the account holds three subscriptions, two of them both displaced on Hosting, which one grant holds, and both recorded as paused
|
||||
- **THEN** the badge's title reads "Billing paused on 2 of 3 subscriptions while a grant holds Hosting."
|
||||
|
||||
#### Scenario: Two grants hold a paused subscription's ladders
|
||||
|
||||
- **WHEN** the account's only subscription pays for a tier of Hosting and a tier of Wiki (a shape only the Stripe Dashboard makes, since Checkout sells one line item), one operator's grant superseded it on Hosting and another on Wiki, and the subscription's row records the console's pause
|
||||
- **THEN** the badge's title reads "Billing paused while grants hold Hosting and Wiki."
|
||||
|
||||
#### Scenario: Two subscriptions paused under their own grants
|
||||
|
||||
- **WHEN** the account holds two subscriptions with one item each, one displaced by a grant on Hosting and the other by another grant on Wiki, and both are recorded as paused
|
||||
- **THEN** the badge's title reads "Billing paused on 2 of 2 subscriptions while grants hold Hosting and Wiki."
|
||||
|
||||
#### Scenario: Several subscriptions, some paused
|
||||
|
||||
- **WHEN** the account holds three subscriptions, one displaced by a grant on Hosting and recorded as paused, and one displaced on Wiki by another subscription
|
||||
- **THEN** the badge's title reads "Billing paused on 1 of 3 subscriptions while a grant holds Hosting. Billing continues on 1 while another subscription holds Wiki."
|
||||
|
||||
#### Scenario: Another subscription holds the ladder
|
||||
|
||||
- **WHEN** the account holds two subscriptions on ladder Hosting, and the later one superseded the earlier one's position
|
||||
- **THEN** the badge's title reads "Billing continues on 1 of 2 subscriptions while another subscription holds Hosting."
|
||||
|
||||
#### Scenario: The default does not count
|
||||
|
||||
- **WHEN** the ladder the organization's subscription was displaced from is held only by the organization type's default
|
||||
- **THEN** the Subscriptions row carries no "Not delivering" badge
|
||||
|
||||
#### Scenario: A subscription that delivers has no badge
|
||||
|
||||
- **WHEN** every subscription of the account delivers on its ladders
|
||||
- **THEN** the Subscriptions row carries no "Not delivering" badge
|
||||
|
||||
#### Scenario: A canceled subscription has no badge
|
||||
|
||||
- **WHEN** the account's only subscription, superseded by an operator's grant, has been canceled
|
||||
- **THEN** the Subscriptions row carries no "Not delivering" badge
|
||||
|
||||
#### Scenario: A subscription whose item left the ladder has no badge
|
||||
|
||||
- **WHEN** an operator's grant superseded the organization's subscription on ladder Hosting, and the subscription's only current item now names a product of another ladder
|
||||
- **THEN** the Subscriptions row carries no "Not delivering" badge
|
||||
|
||||
#### Scenario: A subscription switched above the grant has no badge
|
||||
|
||||
- **WHEN** an operator's grant of Pro superseded the organization's subscription to Plus, and the subscription's item now names Max, ranked above Pro
|
||||
- **THEN** the Subscriptions row carries no "Not delivering" badge
|
||||
|
||||
#### Scenario: A failed read shows the section's error
|
||||
|
||||
- **WHEN** the read of the displacements fails
|
||||
- **THEN** the Subscriptions row shows "Failed to load deliveries." under its value line and no "Not delivering" badge
|
||||
|
||||
#### Scenario: A grant on one ladder of a two-ladder subscription
|
||||
|
||||
- **WHEN** the account's only subscription pays for Plus, a tier of the Wiki and Duo ladders, an operator's grant of Pro, a tier of Wiki alone, superseded it, and the subscription's row records the console's pause
|
||||
- **THEN** the badge's title reads "Billing paused while a grant holds Wiki; nothing is delivered on Duo."
|
||||
|
||||
#### Scenario: The same before Stripe confirms the pause
|
||||
|
||||
- **WHEN** the same subscription's row records no pause
|
||||
- **THEN** the badge's title reads "Billing continues while a grant holds Wiki; nothing is delivered on Duo."
|
||||
|
||||
#### Scenario: A grant of a tier of both ladders
|
||||
|
||||
- **WHEN** the operator's grant over the same subscription is of a product that is a tier of both Wiki and Duo, and the pause is recorded
|
||||
- **THEN** the badge's title reads "Billing paused while a grant holds Duo and Wiki."
|
||||
|
||||
#### Scenario: One of several subscriptions kept off a ladder
|
||||
|
||||
- **WHEN** the account holds three subscriptions, one of which pays for Plus and is displaced by a grant of Pro on Wiki and recorded as paused, while the other two deliver
|
||||
- **THEN** the badge's title reads "Billing paused on 1 of 3 subscriptions while a grant holds Wiki; nothing is delivered on Duo."
|
||||
|
||||
#### Scenario: A later plan on the emptied ladder
|
||||
|
||||
- **WHEN** after the grant of Pro emptied Duo, an operator grants a tier of Duo alone, and the subscription's row records the console's pause
|
||||
- **THEN** the badge's title reads "Billing paused while a grant holds Wiki."
|
||||
|
||||
#### Scenario: A grant and another subscription hold a paused subscription's ladders
|
||||
|
||||
- **WHEN** the account's only subscription pays for Plus, a tier of the Wiki and Duo ladders, a grant of Pro now holds Wiki, another subscription holds Duo, and the subscription's row records the console's pause
|
||||
- **THEN** the badge's title reads "Billing paused while other plans hold Duo and Wiki."
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: A grant's hold pauses the subscription's collection at Stripe
|
||||
|
||||
The console SHALL pause a subscription's payment collection at Stripe while grants hold the ladders that keep every product the subscription pays for from being delivered, and SHALL resume it when they no longer do. A conferral places a product whole, so a grant on one ladder of a product that is a tier of several ladders keeps that product from every one of them, and the other ladders do not stop the pause. The rule holds, except that the switch of a subscription the console paused clears the pause before its update and, unless the update applies, has the reconcile set it again ("A subscription paused for a grant is switched to the granted tier or above"). One pure function, `entitlements.CollectionPauseWanted`, SHALL state the rule, reading no row: collection is wanted paused when the subscription's status is `active`, `trialing`, `past_due` or `unpaid`, at least one of its current items names a product with an entitlement set, every such product is left alone by the displacement rule ("Subscription reconcile computes a genuine diff, grouped by product"), and every displacement that leaves one alone has a grant as its holder. A subscription partly displaced, displaced by another subscription, or whose status is `paused`, `incomplete`, `canceled` or any other SHALL NOT be wanted paused.
|
||||
|
||||
The console's pause SHALL be `pause_collection[behavior]=void`, with no `resumes_at`, set in one update together with the subscription metadata `member_console_collection=paused_for_grant` (the marker). The clear SHALL send `pause_collection=` and `metadata[member_console_collection]=` in one update. A pause without the marker was made outside the console: the console SHALL NOT clear or replace it, and SHALL log it when the rule wants a pause. A marker without a pause SHALL be set again while the rule wants a pause and cleared otherwise.
|
||||
|
||||
The subscription reconcile SHALL evaluate the rule in its active and suspended branches, from reads it makes under the pool row, and compare it with the subscription it fetched from Stripe. A reconcile run by the transactional outbox's `reconcile_subscription` action (payload: the console's subscription id) SHALL make the pause or clear call after its commit and SHALL return a failed call, with an error that names the Stripe subscription id, to the outbox's workflow, which retries the reconcile on its budget and then dead-letters the entry. After a successful call, that reconcile SHALL run once more, at most twice in all, so the decision is made again from a fetch taken after its own call. Every other reconcile that finds Stripe disagreeing with the rule SHALL write one `reconcile_subscription` entry in its own transaction and SHALL NOT call Stripe for the pause. The one exception is the restore of a paused subscription's switch that Stripe did not apply ("A subscription paused for a grant is switched to the granted tier or above"), which runs the outbox's reconcile of the subscription from the member's request.
|
||||
|
||||
Each act that can change who holds a ladder SHALL write a `reconcile_subscription` entry, in its own transaction, for each subscription of the pool's organization whose status is `active`, `trialing`, `past_due` or `unpaid`: issuing a grant through the operator's form, extending one through Manage, a revocation, an expiry, the grandfathered legacy grant of a default change, a tier removal, a member's Resume plan, and a subscription reconcile that ended one of its own provisions (for the organization's other subscriptions). An hourly sweep SHALL write the same entry for each live subscription that is mapped to a Stripe subscription the key can reach and whose recorded pause disagrees with the rule, unless that subscription has an entry that is pending, processing or failed, or dead-lettered within the last day.
|
||||
|
||||
Every reconcile SHALL record the subscription's `pause_collection` behavior, its `resumes_at` and whether the marker is set on `core.subscriptions`, from the subscription it fetched, so the console's pages say what Stripe confirmed. The console SHALL move no money at either edge: it SHALL NOT credit the renewal paid before the grant, and SHALL NOT charge the rest of the period in which the grant ends.
|
||||
|
||||
#### Scenario: A grant over the subscription pauses its collection
|
||||
|
||||
- **WHEN** an organization pays for Plus through subscription S, and an operator issues a Pro grant that supersedes S's position
|
||||
- **THEN** the grant's transaction writes a `reconcile_subscription` entry for S, and the outbox's reconcile of S sets `pause_collection[behavior]=void` and the marker in one Stripe update and sets no `resumes_at`
|
||||
- **AND** the reconcile of the echo records the pause and the marker on S's row
|
||||
|
||||
#### Scenario: A renewal while the grant holds is voided
|
||||
|
||||
- **WHEN** S renews while its collection is paused by the console
|
||||
- **THEN** Stripe finalizes and voids the renewal about an hour after it is created, no charge is attempted, and the console projects the invoice as void
|
||||
|
||||
#### Scenario: The grant's end resumes collection
|
||||
|
||||
- **WHEN** the Pro grant over S is revoked or expires
|
||||
- **THEN** the act writes a `reconcile_subscription` entry for S, and the outbox's reconcile clears `pause_collection` and the marker in one Stripe update
|
||||
- **AND** the console credits and charges nothing for the period in which the grant ended
|
||||
|
||||
#### Scenario: A failed pause call is retried and then dead-lettered
|
||||
|
||||
- **WHEN** Stripe answers the pause call with a server error for a day
|
||||
- **THEN** the outbox's workflow retries the reconcile on its budget, each attempt reading the subscription again, and then dead-letters the entry
|
||||
- **AND** the drift sweep writes a new entry for S once the dead letter is a day old
|
||||
|
||||
#### Scenario: Stripe refuses the call
|
||||
|
||||
- **WHEN** Stripe answers the pause call with a 4xx other than 409 or 429
|
||||
- **THEN** the entry is dead-lettered without retries and S's row records no pause
|
||||
|
||||
#### Scenario: A partly displaced subscription keeps billing
|
||||
|
||||
- **WHEN** S pays for a bundle that is a tier of ladders L1 and L2 and another product that is a tier of L3, and a grant supersedes S on L1 only
|
||||
- **THEN** the rule does not want S paused, because S still delivers its L3 product, and the console sends no pause
|
||||
|
||||
#### Scenario: A grant on one ladder of a bundle pauses the subscription
|
||||
|
||||
- **WHEN** S pays only for a bundle that is a tier of ladders L1 and L2, and a grant of a tier of L1 alone supersedes S's position
|
||||
- **THEN** S holds no live provision on L1 or L2, L2 holds no position, and S's reconcile writes a `reconcile_subscription` entry, because the bundle is left alone whole and the displacement that leaves it alone is the grant's
|
||||
- **AND** the outbox's reconcile of S pauses its collection, as for a tier of one ladder
|
||||
|
||||
#### Scenario: A subscription displaced by another subscription keeps billing
|
||||
|
||||
- **WHEN** subscription S2 of the same organization superseded S's position on S's only ladder
|
||||
- **THEN** the rule does not want S paused, because its holder is a subscription
|
||||
|
||||
#### Scenario: A pause made in the Dashboard is left as it is
|
||||
|
||||
- **WHEN** S carries a pause without the marker, and a grant holds S's only ladder
|
||||
- **THEN** the reconcile sends nothing to Stripe and logs "reconcile: collection paused outside the console; left as it is"
|
||||
- **AND** when the grant ends, the reconcile does not clear that pause
|
||||
|
||||
#### Scenario: Collection resumed in the Dashboard is paused again
|
||||
|
||||
- **WHEN** an operator resumes S's collection in the Stripe Dashboard while the grant holds S's only ladder, leaving the marker
|
||||
- **THEN** the reconcile of the echo writes a `reconcile_subscription` entry, and the outbox's reconcile pauses S again
|
||||
|
||||
#### Scenario: A past-due subscription is paused and its open invoice stays due
|
||||
|
||||
- **WHEN** S is `past_due` with an open invoice when the grant supersedes its position
|
||||
- **THEN** the suspended branch's decision wants S paused and the outbox's reconcile pauses it
|
||||
- **AND** the console voids nothing, and Stripe keeps retrying the open invoice
|
||||
|
||||
#### Scenario: A subscription Stripe holds as paused is not paused for a grant
|
||||
|
||||
- **WHEN** S's status is `paused` and a grant holds its only ladder
|
||||
- **THEN** the rule does not want S paused, and the console sends nothing to Stripe
|
||||
|
||||
#### Scenario: A member's cancellation never makes the pause call
|
||||
|
||||
- **WHEN** a member's cancellation of S ends with a reconcile that finds the rule wanting a pause S does not carry
|
||||
- **THEN** that reconcile writes a `reconcile_subscription` entry, calls Stripe for nothing more, and the cancellation answers as it does today
|
||||
|
||||
#### Scenario: The drift sweep skips a subscription with no reachable mapping
|
||||
|
||||
- **WHEN** the rule wants S paused and S has no Stripe mapping, or a mapping for the other key mode
|
||||
- **THEN** the hourly sweep writes no entry for S
|
||||
|
||||
#### Scenario: A crossed call is decided again
|
||||
|
||||
- **WHEN** the outbox's reconcile of S made its pause call from a fetch taken before the call
|
||||
- **THEN** it fetches S again, decides again, and makes a second call only when S still disagrees with the rule
|
||||
|
||||
#### Scenario: The drift sweep asks for a missed subscription
|
||||
|
||||
- **WHEN** the rule wants S paused, S's row records no pause, and S has no outbox entry
|
||||
- **THEN** the hourly sweep writes one `reconcile_subscription` entry for S, and a second run within the hour, with that entry pending, writes none
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
Tests follow `docs/testing.md`: database tests open the database through `dbtest`, build their rows through the test kit (`internal/testkit/world`) or the package's fixtures, filter to the rows their own fixture made, and pass twice in a row without a reset; tests meet the same complexity limit as production code, so a table runs its rows through a named function. A Stripe test drives `stripetest.MockBackend` with the API state Stripe would return, never a hand-built event payload, and runs without `t.Parallel()`.
|
||||
|
||||
Sections 1 to 8 are the parts of one commit; sections 9 and 10 are not commits. The commit passes `make test` with the test stack up, which runs `make lint` first and then every package one at a time (`-p 1`). Every golden that changes is rewritten with `-update`, read, and named in its item with the change it holds; any other golden diff, and any change to an existing test's result other than the rows an item rewrites, is a stop. A script's usage is learned from its header or source, never by running it.
|
||||
|
||||
**Breaks.** The list of section 8 is written to a file outside the repository and run with `go run ./scripts/mutation-check -list <file> -side new` on the commit, from the repository root, after `test/reset-test-db.sh` and with `test/.env` loaded as `make test` loads it (without it the database tests skip and every record reports SKIPPED), after its green control run; the tests are new or gain rows, so there is no old side. A break of Go code, a template or a generated sqlc file is a test record (`file`, `old`, `new`, `pkg`, `run_new`, `want_new`); a break of a sqlc query edits the generated `.sql.go` file beside the querier, whose constant the test runs. A break caught by two tests is two records, one per test, with the same `file`, `old` and `new`. Every record's `want_new` is a text the failing test prints only when it fails, never a test or subtest name alone. Every record must be CAUGHT. An `old` text below is the code as the items write it; when the committed code words it differently, the record is corrected to the committed text before the run, never dropped, and its `new` text keeps the package compiling. A break that no test of the commit sees is named with the reason, and the commit message says so. The commit message lists every break with its result.
|
||||
|
||||
## 1. A grant on one ladder of a two-ladder subscription (design D1)
|
||||
|
||||
- [x] 1.1 `TestReconcile_AGrantOnOneLadderOfABundlePausesIt` (a new `internal/fulfillment/bundle_grant_test.go`, package `fulfillment`). A helper `alsoOnLadder(t, database, fx) string` adds a second plan ladder whose rank-0 tier is the fixture's product and returns its id, in the style of `secondTier` (`reconcile_integration_test.go:473`). The test: `seedFixture`, `alsoOnLadder`, a mock whose `Sub` is `fx.activeSub(fx.planItem())`, installed, and `reconcile(..., "test:created")`. Precondition, `t.Fatalf`: the subscription's live episodes name both ladders, `precondition: the subscription holds %d ladders before the grant, want 2`. Then `secondTier` and `issueGrant(t, database, fx, prod2, false)` (a tier of the first ladder alone), `grantDelivers` as a precondition, and `reconcile(..., "stripe:customer.subscription.updated")`. Checks, each with `t.Errorf`:
|
||||
- `liveProvisions` is 0: `a grant on one ladder of a bundle: the subscription holds %d live provisions, want 0`;
|
||||
- the pool holds no position on the second ladder: `a grant on one ladder of a bundle: the other ladder holds %d positions, want 0`;
|
||||
- `reconcileEntries` for the subscription is 1: `a grant on one ladder of a bundle: reconcile_subscription entries for %s = %d, want 1`;
|
||||
- the grant still delivers: `a grant on one ladder of a bundle: the grant stopped delivering`.
|
||||
- [x] 1.2 No production code changes in this section: the test pins what the console does today, which the strings of sections 3, 5 and 6 rest on.
|
||||
|
||||
## 2. The ladder's name rides the conferral shapes (design D2)
|
||||
|
||||
- [x] 2.1 `ListConferralShapes` (`internal/entitlements/queries/pool_provision_ladders.sql:293-303`) selects `l.name AS ladder_name` with `JOIN core.plan_ladders l ON l.plan_ladder_id = s.plan_ladder_id`; its comment says the name is for the operator's titles and the reconcile ignores it. `make sqlc-generate`; `ListConferralShapesRow` gains `LadderName string`.
|
||||
- [x] 2.2 `Rung` (`internal/entitlements/displacement.go:12-16`) gains `LadderName string`, and `RungsByProduct` (`:24-36`) sets it. The doc comment of `Rung` says that only the operator's titles read the name.
|
||||
- [x] 2.3 `ListLiveLadderIDsBySubscriptionIDs` (`internal/entitlements/queries/pool_provision_transitions.sql`, beside `ListSubscriptionDisplacementsBySubscriptionIDs` at `:121`): for the listed subscription ids, the subscription id and plan ladder id of each live position (the rows `ListLadderPositionsByPool` returns) of the subscription's organization's active default pool, joined as the displacements statement joins it; its comment says it is one statement for the subscriptions list, never one per row. `make sqlc-generate`.
|
||||
- [x] 2.4 `TestOperatorPagesReadAFixedNumberOfTimes` passes unedited: it measures what two more rows cost (`perRow`, 0 for `billing subscriptions` and for `organization page`, `internal/server/operator_read_count_db_test.go:67` and `:137`), and the list's new statement is one per page, so no expected count in it changes; it pins that the live read is not per row. `TestProductsPageReadsPayingOnce` passes unedited. Tests that build `ListConferralShapesRow` or `Rung` literals compile unchanged.
|
||||
|
||||
## 3. The Not delivering badge names every ladder (design D2, Copy B0 to B5)
|
||||
|
||||
- [x] 3.1 `internal/server/operator_enrollment.go`: `displacedSubscription` (`:486-490`) gains `Emptied []displacedLadder` with its doc comment. `displacedOnPage` (`:501-516`) gains a last parameter `live map[string]bool` (ladder ids with a live position when the page renders), indexes the displacements by ladder keeping each ladder's first row, and for each product the rule leaves alone walks `rungs[p]` in order: a rung whose ladder is indexed appends `displacedLadder{ID: d.LadderID, Name: d.LadderName}` and the row's holder, once per ladder for the subscription; a rung with no row whose ladder is not in `live` appends `displacedLadder{ID: r.LadderID, Name: r.LadderName}` to `Emptied`; a rung with no row whose ladder is in `live` appends nothing. `Paused` keeps its test (`:514`): `grantsOnly` is computed from `left[p]` only, never from the holders the walk adds (design D2, Paused). The doc comment says that a product left alone delivers nothing on any of its ladders, so each is named unless something else holds it. `displacedByID` (`:1889`), `displacementTitleFor` (`:1921`) and `subscriptionTitlesFor` (`operator_billing.go:642`) take the live ladder ids and pass them down: the card a `map[string]bool` of the default pool's, the list a map of live ladder ids by subscription id.
|
||||
- [x] 3.2 `loadOrgPools` (`:1540`) returns, beside the pools, the ladder ids of the active default pool's positions (`defaultPoolID`, `:1840`), read from the `positions` it already holds (`:1570-1578`); `loadOrgBillingSummary` passes them through `fillDisplacement` and `accountDisplacementTitle` (`:1821`, `:1852`) to `displacementTitleFor`. A failed read of that pool's positions sets `DisplacementError` as the other reads do. `subscriptionDisplacementTitles` (`operator_billing.go:615`) runs `ListLiveLadderIDsBySubscriptionIDs` once with the other three reads and returns its error as they do. No other statement is added to either page. The failure rows: `enrollmentReadFailures` (`operator_enrollment_read_failures_db_test.go:378`) gains `{"a failed position read names the badge error", "entitlements", "ListLadderPositionsByPool", wantDisplacementError("ListLadderPositionsByPool")}`; `subscriptionsListReadFailures` (`operator_subscriptions_displacement_db_test.go:129`) gains `{"a failed live ladder read", "entitlements", "ListLiveLadderIDsBySubscriptionIDs"}`, with that method on `failingEnrollmentEntitlementsQ` (`operator_enrollment_read_failures_db_test.go:140`, the same shape as `ListLadderPositionsByPool`).
|
||||
- [x] 3.3 `titleGroup` gains `emptied`; `groupDisplaced` (`:528-557`) collects the distinct emptied ladders of the group's subscriptions, drops any the group names as held, and sorts them as it sorts `ladders`. A method `emptiedClause()` returns `""` when `emptied` is empty and otherwise `"; nothing is delivered on " + names`, `names` through `joinWithAnd`. `pausedSentence` and the three forms of `continuingSentence` (`:570-590`) insert it before their period; the afterPaused form counts `g.count`. `pausedSentence` words its holder through `pausedHolder` when every holder the group names has `Source` `grant`, and through `displacementHolder` otherwise. `displacementTitle`'s doc comment (`:592-609`) adds the clause to its forms.
|
||||
- [x] 3.4 `TestDisplacedOnPage` (`internal/server/operator_enrollment_render_test.go:1530`, rows in `displacedOnPageRows`, `:1484`): `displacedOnPageRow` gains `live map[string]bool` after `displacements`, and every existing row gains `nil` there. Rows (failure text unchanged: `displacedOnPage(%s) = %+v, want %+v`):
|
||||
- `a bundle displaced on one ladder` (rewritten): rungs `bundle` is `{LadderID "l-hosting", LadderName "Hosting", Rank 1}` then `{LadderID "l-support", LadderName "Support", Rank 1}`; displacements `held("l-support", "Support")`; `live` `{"l-support": true}`; behavior ""; wants `Ladders` Support, `Holders` the grant, `Emptied` Hosting (`{ID "l-hosting", Name "Hosting"}`);
|
||||
- `a bundle displaced on both ladders by one grant`: the same rungs; displacements `held("l-hosting", "Hosting")` and `held("l-support", "Support")`; `live` both ids; wants `Ladders` Hosting then Support, `Holders` the grant twice, no `Emptied`;
|
||||
- `a bundle displaced on one ladder, the other held`: the same rungs and displacements as the first row; `live` `{"l-hosting": true, "l-support": true}`; wants `Ladders` Support, `Holders` the grant, no `Emptied`;
|
||||
- `a bundle held by a grant on one ladder and a subscription on the other`: the same rungs; displacements `held("l-hosting", "Hosting")` and `{LadderID "l-support", LadderName "Support", HolderProvisionID "p-sub", HolderSource "subscription"}`; `live` both ids; behavior `"void"`; wants `Ladders` Hosting then Support, `Holders` the grant then `{ProvisionID "p-sub", Source "subscription"}`, `Paused` true, no `Emptied`.
|
||||
- [x] 3.5 `TestDisplacementTitle` (`:1465`, rows in `displacementTitleRows`, `:1406`) gains rows through a helper `withEmptied(d displacedSubscription, emptied ...displacedLadder) displacedSubscription`, failure text unchanged (`displacementTitle(%s) = %q, want %q`):
|
||||
- `one subscription, paused, with an emptied ladder`: "Billing paused while a grant holds Wiki; nothing is delivered on Duo.";
|
||||
- `one subscription, continuing, with an emptied ladder`: "Billing continues while a grant holds Wiki; nothing is delivered on Duo.";
|
||||
- `two of three subscriptions paused, with an emptied ladder`: "Billing paused on 2 of 3 subscriptions while a grant holds Wiki; nothing is delivered on Duo.";
|
||||
- `one of two continuing, with an emptied ladder`: "Billing continues on 1 of 2 subscriptions while a grant holds Wiki; nothing is delivered on Duo.";
|
||||
- `paused and continuing, the paused one with an emptied ladder`: "Billing paused on 1 of 3 subscriptions while a grant holds Wiki; nothing is delivered on Duo. Billing continues on 1 while another subscription holds Hosting.";
|
||||
- `paused and continuing, the continuing one with an emptied ladder`: "Billing paused on 1 of 3 subscriptions while a grant holds Hosting. Billing continues on 1 while another subscription holds Wiki; nothing is delivered on Duo.";
|
||||
- `paused under two grants, with an emptied ladder`: "Billing paused while grants hold Hosting and Wiki; nothing is delivered on Duo.";
|
||||
- `other plans hold, with an emptied ladder`: "Billing continues while other plans hold Hosting and Wiki; nothing is delivered on Duo.";
|
||||
- `a ladder emptied for one subscription and held for another`: two paused subscriptions under one grant, one displaced on Duo, the other on Wiki and with Duo emptied: "Billing paused on 2 of 3 subscriptions while a grant holds Duo and Wiki.";
|
||||
- `two emptied ladders, out of name order`: emptied Duo then Docs: "Billing paused while a grant holds Wiki; nothing is delivered on Docs and Duo.";
|
||||
- `paused, held by a grant on one ladder and a subscription on the other`: one subscription, `Ladders` Hosting and Wiki, `Holders` the grant and the subscription (`sub`), `Paused` true: "Billing paused while other plans hold Hosting and Wiki.".
|
||||
- [x] 3.6 `TestSubscriptionsListNamesEveryLadderOfABundle` (a new `internal/server/operator_subscriptions_bundle_db_test.go`): a World with a product that is a tier of two ladders, Near and Far, and a product that is a tier of Near alone; an organization with a billing account, a mapped subscription to the first product, a grant of the second over it, and `w.PauseCollection`. The list's row title (which reads the new live statement) equals "Billing paused while a grant holds " + Near's name + "; nothing is delivered on " + Far's name + "."; failure text `the bundle's row title = %q, want %q`. The organization page's card title equals it; failure text `the bundle's list title %q differs from the organization page's %q`. Then a grant of a tier of Far: the row title equals "Billing paused while a grant holds " + Near's name + "."; failure text `the title after Far is granted = %q, want %q`.
|
||||
- [x] 3.7 `TestOrgEnrollmentTwoLadderGolden` (a new `internal/server/operator_enrollment_two_ladder_golden_db_test.go`, beside `TestOrgEnrollmentGolden`) builds its own World, an `enrollmentFixture` made as `newEnrollmentFixture` makes its own (`newEnrollmentBuilder(t, world.New(t, dbtest.Open(t)))`), so `cut` (`operator_enrollment_golden_db_test.go:254-264`) keeps only the products carrying this World's suffix and no existing state's Product select gains `Both` or `Higher`. `newEnrollmentFixture` and its "ten states" comments (`:6`, `:160`) stay as they are, since its count stays ten. The test gains the state `grant-over-two-ladder-subscription-paused`: products `Both` (a tier of ladders `Near` and `Far`, priced) and `Higher` (a tier of `Near` alone), a subscription to `Both` with its period end, `w.PauseCollection`, then a grant of `Higher`. Goldens `internal/server/testdata/org_enrollment/grant-over-two-ladder-subscription-paused.html` and `.json`, written with `-update` and read: the Subscriptions line's badge is titled "Billing paused while a grant holds Near; nothing is delivered on Far.". Task 5.7 adds the state `two-ladder-subscription` to the same test.
|
||||
|
||||
## 4. The forms library: an option's own hint (design D3)
|
||||
|
||||
- [x] 4.1 `internal/forms/forms.go`: `Option` (`:474-481`) gains `Hint string`, its doc comment saying it is the line an option that carries `ShowsHint` shows in place of the select's declared `Hint` while it is chosen, read only with `ShowsHint`. `Field.HintOnChoice`'s doc comment (`:546-553`) names it.
|
||||
- [x] 4.2 `internal/forms/render.go`: `OptionView` (`:335-342`) gains `Hint`, rendered as `data-hint`; `selectOptions` (`:1092`) copies `o.Hint` only when `o.ShowsHint`. `FieldView` gains `HintDefault`, rendered as `data-hint-default` on the line. In the HintOnChoice branch (`:988-1000`), `choiceHint` (`:1109-1118`) answers the selected option's own hint when it shows one, the declared hint when any offered option shows it, and `""` otherwise; `HintDefault` is the declared hint when any offered option carries its own; the other branch clears `Hint` with `ShowsHint`.
|
||||
- [x] 4.3 `internal/embeds/templates/partials/ui_form_field.html`: the option (`:113`) renders `{{ if .Hint }} data-hint="{{ .Hint }}"{{ end }}`, and the hint line (`:87`) `{{ if .HintDefault }} data-hint-default="{{ .HintDefault }}"{{ end }}`.
|
||||
- [x] 4.4 `internal/embeds/static/hint-on-choice.js`: `sync` (`:16-32`), when the chosen option shows the hint, sets `hint.textContent` to the option's `data-hint`, or to the line's `data-hint-default` when the option carries none and the line carries one, and leaves the text as it is otherwise. The header comment says so.
|
||||
- [x] 4.5 `TestRenderHintOnChoiceShowsTheChosenOptionsOwnHint` (`internal/forms/render_test.go`, beside `TestRenderHintOnChoiceFollowsTheChosenOption`): a `HintOnChoice` select declaring "B is special." with options A, B (`ShowsHint`), C (`ShowsHint`, `Hint` "C is more special.") and D (`Hint` "D is never shown.", no `ShowsHint`). Failure texts:
|
||||
- `option C renders data-hint %q, want %q`;
|
||||
- `option B renders data-hint %q, want none`;
|
||||
- `an option that does not show the hint renders data-hint %q, want none`;
|
||||
- `the hint line's default = %q, want the declared hint`;
|
||||
- `a refused submission that chose %s renders the hint %q hidden %v, want %q shown` (rows C and B);
|
||||
- with the options A and B alone: `with no option's own hint the line carries default %q, want none`.
|
||||
- [x] 4.6 `TestRenderHintOnChoiceFollowsTheChosenOption` and `TestInvariantsHintOnChoiceBelongsToAStackedSelectWithItsHint` pass unedited.
|
||||
|
||||
## 5. The grant form's hint names the ladders of a superseded bundle (design D3, Copy G1)
|
||||
|
||||
- [x] 5.1 `internal/server/operator_enrollment.go`: `subscriptionHeldLadders` (`:2144-2158`) becomes `subscriptionPositions`, returning the position rows with `SubscriptionID` set that it reads today; `productsOnLadders` (`:2123-2136`) becomes `tiersOnLadders`, returning the rows of `ListTiersWithProductsByLadderIDs` it reads today, none on a failed read. A pure `supersededLadders(positions, tiers) map[string][]string` has an entry for a product only when some subscription provision that holds a ladder the product is a tier of holds two or more ladders; the entry is every ladder those provisions hold, ids sorted. Its doc comment cites `core.confer`'s whole-bundle end.
|
||||
- [x] 5.2 `loadIssuanceProducts(r, pools, names *operatorNames)` (`:2092-2117`) sets `SupersedesSubscription` when the product is a tier of a ladder a subscription provision holds (today's marker, from `tiersOnLadders`) and `SupersededLadders` through a pure `ladderNames(ids []string, name func(string) (string, bool)) []string`, called only for a product with a `supersededLadders` entry (two or more ids), so an ordinary Issue grant POST reads no names; it answers the names sorted by `strings.Compare`, or nil when any id has no name. `IssuanceProductOption` (`:315-321`) gains `SupersededLadders []string`, its doc comment updated. `loadOrgEnrollmentData` passes its `names` (`:1526`); `IssueGrant` passes `h.names(r.Context())` (`:862`).
|
||||
- [x] 5.3 `internal/server/operator_enrollment_forms.go`: `issueGrantSupersedeHintOn(ladders []string) string` answers `""` when `ladders` is empty (`supersededLadders` already guarantees two or more) and otherwise "This grant would supersede a tier a subscription pays for on " + `joinWithAnd(ladders)` + "; its billing pauses until the grant ends."; its doc comment says why the ladders are named (whole-bundle end) and that billing pauses in this case (design Context). `issuanceProductOptions` (`:198-211`) sets `forms.Option.Hint` from it. `issueGrantSupersedeWarning`'s comment (`:130-137`) says the console pauses collection "while a grant holds every ladder the subscription pays for", which is no longer true (a grant on one ladder of a bundle pauses it); it is rewritten to say that the console pauses collection while grants hold the ladders that keep every product the subscription pays for from being delivered (`stripe-subscription-management`, "A grant's hold pauses the subscription's collection at Stripe"), and names the per-option form.
|
||||
- [x] 5.4 `TestSupersededLadders` (a new `internal/server/operator_grant_hint_test.go`, no database), rows: `a one-ladder subscription` (no entry); `a bundle subscription, a product of its first ladder`; `a bundle subscription, a product of its other ladder`; `a product of no held ladder` (absent); `a one-ladder subscription and a bundle on the product's ladders` (one on L1, a bundle on L2 and L3: L1, L2 and L3); `two one-ladder subscriptions on the product's two ladders` (one subscription on L1, another on L2, the product a tier of both: no entry); `a suspended bundle position` (both ladders, counted). Failure text `supersededLadders(%s)[%s] = %v, want %v`.
|
||||
- [x] 5.5 `TestLadderNamesNeedsEveryName` (same file), rows `every name read` (sorted by name, not by id) and `one name missing` (nil). Failure text `ladderNames(%s) = %v, want %v`.
|
||||
- [x] 5.6 `TestOrgEnrollmentIssueGrantSupersedeHint` (`operator_enrollment_render_test.go:530`) gains products `Bundle` (`SupersedesSubscription` true, `SupersededLadders` Duo and Wiki) and `Single` (`SupersedesSubscription` true, no `SupersededLadders`), so both options carry `ShowsHint` and `data-hint` renders on `Bundle`'s alone. Failure texts:
|
||||
- `the option of a product that supersedes a bundle carries data-hint %q, want %q`;
|
||||
- `the option of a product that supersedes a one-ladder tier carries data-hint %q, want none`;
|
||||
- `the Product hint carries data-hint-default %q, want Copy W`;
|
||||
- `a refused submission that chose a bundle's product renders the hint %q, want %q`.
|
||||
- [x] 5.7 `TestOrgEnrollmentTwoLadderGolden` gains the state `two-ladder-subscription`: the same products as 3.7, a subscription to `Both` and no grant. Goldens `internal/server/testdata/org_enrollment/two-ladder-subscription.html` and `.json`, written with `-update` and read: `Both` and `Higher` are marked ⚠ and carry `data-hint="This grant would supersede a tier a subscription pays for on Far and Near; its billing pauses until the grant ends."`, and the hint line carries `data-hint-default` with Copy W.
|
||||
- [x] 5.8 The `.json` of the ten existing states that list issuance products (every `.json` under `internal/server/testdata/org_enrollment/` but `no-org.json`) is rewritten with `-update` and read: each gains only `SupersededLadders` on each product. No existing `.html` changes: `rich`, `rich-history-search` and `suspended-subscription` mark only products whose superseded provision holds one ladder.
|
||||
- [x] 5.9 `TestGrantSupersedeHintWalkthrough` (`test/e2e/operator-walkthroughs/grant_supersede_hint_test.go`) gains a step: when the select offers an option carrying `data-hint`, choosing it makes the line read that text, and choosing a marked option without `data-hint` after it makes the line read Copy W again; failure texts `after choosing a bundle's product, the Product hint reads %q, want %q` and `after choosing a one-ladder product, the Product hint reads %q, want Copy W`. With no such option it skips that step naming its precondition ("no org's Issue grant form offers a product that supersedes a bundle"), as the walkthrough skips today without a marked product.
|
||||
|
||||
## 6. The paid tier's card on an empty ladder (design D4, Copy P0 to P4)
|
||||
|
||||
- [x] 6.1 `internal/server/plan_offer.go`: an unexported `grantHolds(pos entitlements.ListLadderPositionsByPoolRow) bool` (`GrantID` set, `DefaultSourced` false), which `payingFrom` (`:194`) calls. `positionLoader` keeps the rows `payingOn` read (`:91-107`). A pure `grantHeldLadder(row, rows, held, ladders) (ladder, granted string, ok bool)` walks `ladders` in order and answers the first ladder, other than `row.PlanLadderID`, that `rows` names for `row.SubscriptionID` and `row.ProductID` and whose held position `grantHolds`, with the ladder's name and the position's `ProductName`.
|
||||
- [x] 6.2 `internal/server/member_plans_paid_under_grant.go`: `payingCard` (`:17-23`) gains `grantLadder string`. `setPaidUnderGrant` (`:60-107`), after `CollectionPaused` (`:85`) and before `LadderID`, for a card with `grantLadder` sets `GrantedLadder`, sets `MovesTo` and `MovesOn` only when `paying.cancelDate()` is empty and under the existing downgrade test, the order of the existing switch (`:92-99`), and returns. Its doc comment says that the card on an empty ladder states the subscription's state and draws no control.
|
||||
- [x] 6.3 `internal/server/member_plans.go`: `plansBuild` (`:377-388`) keeps the ladders `buildPlansData` read (`:343-346`). `buildLadder`, beside the grant-held case (`:407-411`), when `paying` is set, `held.ProductID` is empty and `grantHeldLadder` answers, sets `facts.Paid` with `grantedName` and `grantLadder`; the heading's cancellation date comes from `facts.Paid` only when `grantLadder` is empty (`:423-425`). `TierViewModel` gains `GrantedLadder string` after `GrantedUntil` (`:228`), its doc comment naming the empty ladder.
|
||||
- [x] 6.4 `internal/embeds/templates/partials/member_plans.html`: each of the three paused branches (`:87-92`) inserts `{{ if .GrantedLadder }} on {{ .GrantedLadder }}{{ end }}` right after "is granted"; a branch after them, `{{ else if .GrantedLadder }}`, draws `Billing continues while {{ .GrantedName }} is granted on {{ .GrantedLadder }}`. The comment above the chain (`:62-83`) adds the card on an empty ladder.
|
||||
- [x] 6.5 `TestGrantHeldLadder` (a new `internal/server/member_plans_vacated_test.go`, no database), rows: `a grant holds the other ladder` (Wiki, Pro); `nothing holds the other ladder`; `a default position is not a grant`; `a subscription holds the other ladder`; `two ladders a grant holds` (rows name Hosting before Wiki, the page orders Wiki first: Wiki); `another subscription's ladder`; `a product of one ladder`. Failure text `grantHeldLadder(%s) = %q, %q, %v, want %q, %q, %v`.
|
||||
- [x] 6.6 `TestPaidPlanCardOnAVacatedLadder` (same file), built as `TestPaidPlanCardUnderAGrant` (`member_plans_paid_under_grant_test.go:57-150`) with `payingCard{..., grantedName: "Pro", grantLadder: "Wiki"}` and rows: `confirmed` and `confirmed, trialing` ("Paused while Pro is granted on Wiki"); `payment due` ("Paused while Pro is granted on Wiki, payment due"); `downgrade scheduled` ("Paused while Pro is granted on Wiki, moves to Basic on Jan 15, 2099"); `cancellation scheduled` ("Paused while Pro is granted on Wiki"); `unconfirmed`, `unconfirmed, payment due`, `unconfirmed, cancellation scheduled` and `unconfirmed, downgrade scheduled` ("Billing continues while Pro is granted on Wiki"; the last pins `{{ else if .GrantedLadder }}` before `{{ else if .MovesTo }}` in the template, `member_plans.html:93`); `period unknown` ("Billing details are still being processed."); `paused by Stripe` ("$10.00/month, paused"). Every row draws no control. Failure text `%s: the vacated card's price line %q and controls %v, want %q and none`.
|
||||
- [x] 6.7 `TestMemberPlansGolden`, rewritten with `-update` and read:
|
||||
- `.html` of seven states, each changing only in Duo's Plus card: `paid-plus-under-granted-pro` (P0), `paid-plus-under-granted-pro-past-due` (P1), `paid-plus-under-granted-pro-unconfirmed` (P3), `paid-plus-under-granted-pro-cancellation-scheduled` (P0, and Duo's heading unchanged), `paid-plus-under-granted-pro-downgrade-scheduled` (P2), `paid-plus-under-granted-pro-until` (P0, no date) and `paid-plus-paused-under-granted-pro` (P4 with "Entitlements are suspended."); each has no control on Duo;
|
||||
- the `.json` of every state under `internal/server/testdata/member_plans/` gains `GrantedLadder` on each tier, and the seven states' Duo Plus tier also changes its paid-card fields.
|
||||
- [x] 6.8 `TestPaidPlanCardUnderAGrant`, `TestProductsPageReadsPayingOnce`, `TestPayingByLadder` and `TestOffer` pass unedited.
|
||||
|
||||
## 7. The documents (design D5)
|
||||
|
||||
- [x] 7.1 `docs/models/plan-ladders-transitions.md`, invariant 7 (`:67`): after "inside one `core.confer` call", the rule for a subscription's product that is a tier of several ladders under a grant on some of them, as design D5 words it, naming #88 and #154 for the two alternatives not taken.
|
||||
- [x] 7.2 `docs/stripe.md:216`, "A grant over a paid plan": after the titles, the bundle case: the tier ends on every ladder, the console pauses collection as for one ladder, the title's clause ("Billing paused while a grant holds {ladder}; nothing is delivered on {ladders}."), and on each other ladder the paid tier's card reads "Paused while {granted} is granted on {ladder}", or "Billing continues while {granted} is granted on {ladder}" until Stripe confirms the pause, with no control.
|
||||
- [x] 7.3 `docs/plan-management.md:157` (step 3): the warning names every ladder when the superseded tier is a tier of several. `:164`: the title's clause and the card on the other ladders.
|
||||
- [x] 7.4 No other document changes: `docs/testing.md` describes no hint attribute, and `docs/database-locks.md` no read of `ListConferralShapes`.
|
||||
|
||||
## 8. Breaks
|
||||
|
||||
- [x] 8.1 The list:
|
||||
- `DisplacedProducts` passing over a product of several rungs (`internal/entitlements/displacement.go`, `old` ` for _, p := range missing {`, `new` the same line followed by `if len(rungs[p]) > 1 { continue }`): caught by `TestReconcile_AGrantOnOneLadderOfABundlePausesIt`, `pkg` `./internal/fulfillment`, `want_new` `a grant on one ladder of a bundle: reconcile_subscription entries`;
|
||||
- `displacedOnPage` adding no emptied ladder (the `Emptied` append removed): caught by `TestDisplacedOnPage/a_bundle_displaced_on_one_ladder`, `want_new` `displacedOnPage(a bundle displaced on one ladder)`, and by `TestOrgEnrollmentTwoLadderGolden/grant-over-two-ladder-subscription-paused`, `want_new` `grant-over-two-ladder-subscription-paused.html differs`;
|
||||
- `displacedOnPage` naming as held only the ladder of the deciding displacement (the indexed-rung test reduced to `r.LadderID == left[p].LadderID`): caught by `TestDisplacedOnPage/a_bundle_displaced_on_both_ladders_by_one_grant`, `want_new` `displacedOnPage(a bundle displaced on both ladders by one grant)`;
|
||||
- `groupDisplaced` keeping an emptied ladder the group names as held (the filter removed): caught by `TestDisplacementTitle/a_ladder_emptied_for_one_subscription_and_held_for_another`, `want_new` `displacementTitle(a ladder emptied for one subscription and held for another)`;
|
||||
- `continuingSentence`'s one-subscription form without the clause: caught by `TestDisplacementTitle/one_subscription,_continuing,_with_an_emptied_ladder`, `want_new` `displacementTitle(one subscription, continuing, with an emptied ladder)`;
|
||||
- `displacedOnPage` ignoring `live` (the `live` test removed, every unheld rung emptied): caught by `TestDisplacedOnPage/a_bundle_displaced_on_one_ladder,_the_other_held`, `want_new` `displacedOnPage(a bundle displaced on one ladder, the other held)`, and by `TestSubscriptionsListNamesEveryLadderOfABundle`, `want_new` `the title after Far is granted`;
|
||||
- `pausedSentence` using `pausedHolder` whatever the holders: caught by `TestDisplacementTitle/paused,_held_by_a_grant_on_one_ladder_and_a_subscription_on_the_other`, `want_new` `displacementTitle(paused, held by a grant on one ladder and a subscription on the other)`;
|
||||
- `displacedOnPage` taking `grantsOnly` over every holder the walk adds: caught by `TestDisplacedOnPage/a_bundle_held_by_a_grant_on_one_ladder_and_a_subscription_on_the_other`, `want_new` `displacedOnPage(a bundle held by a grant on one ladder and a subscription on the other)`;
|
||||
- the organization page dropping the positions read's error from the badge (the `DisplacementError` assignment for it removed): caught by `TestOrgEnrollmentReadFailures/a_failed_position_read_names_the_badge_error`, `want_new` `DisplacementError after a failed ListLadderPositionsByPool read`;
|
||||
- `subscriptionDisplacementTitles` ignoring the live ladder read's error (its `return` removed): caught by `TestSubscriptionsListDisplacementReadFailures/a_failed_live_ladder_read`, `want_new` `DisplacementError after a failed ListLiveLadderIDsBySubscriptionIDs read`;
|
||||
- `ListLiveLadderIDsBySubscriptionIDs` answering no row (in its generated `.sql.go`, the `WHERE` made false): caught by `TestSubscriptionsListNamesEveryLadderOfABundle`, `want_new` `the title after Far is granted`;
|
||||
- `ListConferralShapes` selecting no name (in `internal/entitlements/pool_provision_ladders.sql.go`, `old` `l.name AS ladder_name`, `new` `''::text AS ladder_name`): caught by `TestSubscriptionsListNamesEveryLadderOfABundle`, `want_new` `the bundle's row title`, and by `TestOrgEnrollmentTwoLadderGolden/grant-over-two-ladder-subscription-paused`, `want_new` `grant-over-two-ladder-subscription-paused.html differs`;
|
||||
- `RungsByProduct` leaving `LadderName` empty: caught by `TestSubscriptionsListNamesEveryLadderOfABundle`, `want_new` `the bundle's row title`;
|
||||
- `supersededLadders` answering an entry for a product whose superseded provisions each hold one ladder: caught by `TestSupersededLadders/two_one-ladder_subscriptions_on_the_product's_two_ladders`, `want_new` `supersededLadders(two one-ladder subscriptions on the product's two ladders)`;
|
||||
- `supersededLadders` keeping only the ladders the product is a tier of: caught by `TestSupersededLadders/a_bundle_subscription,_a_product_of_its_first_ladder`, `want_new` `supersededLadders(a bundle subscription, a product of its first ladder)`;
|
||||
- `ladderNames` answering the names it read when one is missing: caught by `TestLadderNamesNeedsEveryName/one_name_missing`, `want_new` `ladderNames(one name missing)`;
|
||||
- `issuanceProductOptions` leaving `Option.Hint` empty: caught by `TestOrgEnrollmentIssueGrantSupersedeHint`, `want_new` `the option of a product that supersedes a bundle carries data-hint`, and by `TestOrgEnrollmentTwoLadderGolden/two-ladder-subscription`, `want_new` `two-ladder-subscription.html differs`;
|
||||
- `issueGrantSupersedeHintOn` answering for no ladders (its empty test removed): caught by `TestOrgEnrollmentIssueGrantSupersedeHint`, `want_new` `the option of a product that supersedes a one-ladder tier carries data-hint`;
|
||||
- `selectOptions` not copying `Hint`: caught by `TestRenderHintOnChoiceShowsTheChosenOptionsOwnHint`, `pkg` `./internal/forms`, `want_new` `option C renders data-hint`;
|
||||
- `selectOptions` copying `Hint` without `ShowsHint`: caught by the same test, `want_new` `an option that does not show the hint renders data-hint`;
|
||||
- `choiceHint` answering the declared hint for a chosen option with its own: caught by the same test, `want_new` `a refused submission that chose C renders the hint`;
|
||||
- `HintDefault` left empty: caught by the same test, `want_new` `the hint line's default`;
|
||||
- `HintDefault` set when no option carries its own hint: caught by the same test, `want_new` `with no option's own hint the line carries default`;
|
||||
- `ui_form_field.html` without `data-hint` on the option: caught by `TestOrgEnrollmentIssueGrantSupersedeHint`, `want_new` `the option of a product that supersedes a bundle carries data-hint`;
|
||||
- `ui_form_field.html` without `data-hint-default` on the line: caught by the same test, `want_new` `the Product hint carries data-hint-default`;
|
||||
- `grantHolds` without its `DefaultSourced` test: caught by `TestGrantHeldLadder/a_default_position_is_not_a_grant`, `want_new` `grantHeldLadder(a default position is not a grant)`;
|
||||
- `grantHeldLadder` walking `rows` in place of `ladders`: caught by `TestGrantHeldLadder/two_ladders_a_grant_holds`, `want_new` `grantHeldLadder(two ladders a grant holds)`;
|
||||
- `grantHeldLadder` without its subscription test: caught by `TestGrantHeldLadder/another_subscription's_ladder`, `want_new` `grantHeldLadder(another subscription's ladder)`;
|
||||
- `setPaidUnderGrant` drawing the controls on a card with `grantLadder` (its return moved after the controls): caught by `TestPaidPlanCardOnAVacatedLadder/confirmed`, `want_new` `confirmed: the vacated card's price line`;
|
||||
- the template's plain paused branch without ` on {{ .GrantedLadder }}`: caught by `TestPaidPlanCardOnAVacatedLadder/confirmed`, `want_new` `confirmed: the vacated card's price line`;
|
||||
- the payment-due paused branch without it: caught by `TestPaidPlanCardOnAVacatedLadder/payment_due`, `want_new` `payment due: the vacated card's price line`;
|
||||
- the moves-to paused branch without it: caught by `TestPaidPlanCardOnAVacatedLadder/downgrade_scheduled`, `want_new` `downgrade scheduled: the vacated card's price line`;
|
||||
- the template without the "Billing continues" branch: caught by `TestPaidPlanCardOnAVacatedLadder/unconfirmed`, `want_new` `unconfirmed: the vacated card's price line`;
|
||||
- `buildLadder` without the empty-ladder case: caught by `TestMemberPlansGolden/paid-plus-under-granted-pro`, `want_new` `paid-plus-under-granted-pro.html differs`;
|
||||
- `buildLadder` taking the heading's cancellation date from a card with `grantLadder`: caught by `TestMemberPlansGolden/paid-plus-under-granted-pro-cancellation-scheduled`, `want_new` `paid-plus-under-granted-pro-cancellation-scheduled.html differs`;
|
||||
- `pausedSentence`'s one-subscription form without the clause: caught by `TestDisplacementTitle/one_subscription,_paused,_with_an_emptied_ladder`, `want_new` `displacementTitle(one subscription, paused, with an emptied ladder)`;
|
||||
- `pausedSentence`'s several-subscriptions form without the clause: caught by `TestDisplacementTitle/two_of_three_subscriptions_paused,_with_an_emptied_ladder`, `want_new` `displacementTitle(two of three subscriptions paused, with an emptied ladder)`;
|
||||
- `continuingSentence`'s count form without the clause: caught by `TestDisplacementTitle/paused_and_continuing,_the_continuing_one_with_an_emptied_ladder`, `want_new` `displacementTitle(paused and continuing, the continuing one with an emptied ladder)`;
|
||||
- `continuingSentence`'s several-subscriptions form without the clause: caught by `TestDisplacementTitle/one_of_two_continuing,_with_an_emptied_ladder`, `want_new` `displacementTitle(one of two continuing, with an emptied ladder)`;
|
||||
- `groupDisplaced` without its sort of the emptied ladders (`slices.SortFunc(g.emptied, byNameThenID)` removed): caught by `TestDisplacementTitle/two_emptied_ladders,_out_of_name_order`, `want_new` `displacementTitle(two emptied ladders, out of name order)`.
|
||||
- [x] 8.2 Unseen, named in the commit message: `hint-on-choice.js` not setting the line's text. Only the browser walkthrough reads it, and its step skips because no demo state holds a subscription to a product of two ladders (design, What is unverified); the hand captures of 9.2 show it.
|
||||
|
||||
## 9. Captures
|
||||
|
||||
- [x] 9.1 `make screens`: no demo state holds a subscription to a product of two ladders, so no capture changes; a change is read and named.
|
||||
- [x] 9.2 Hand captures at 1280 desktop width and 390 mobile width (emulated, `innerWidth` checked), on the test stack with the kit's states loaded, attached to the commit's review: the Issue grant form of an organization in `two-ladder-subscription` with `Higher` chosen (G1) and then a one-ladder product chosen (Copy W); the organization page in `grant-over-two-ladder-subscription-paused`, its Billing card with the badge (the title is an attribute no capture shows; the golden pins it); the member's Products page in `paid-plus-under-granted-pro` (P0 on Duo) and in its unconfirmed form (P3).
|
||||
|
||||
## 10. Landing
|
||||
|
||||
- [x] 10.1 With the test stack up, `make test` passes (it runs `make lint` first), and `openspec validate grant-names-every-emptied-ladder --strict` passes.
|
||||
- [x] 10.2 The break list of section 8 reads CAUGHT in the commit message, with 8.2 named.
|
||||
- [ ] 10.3 After the push, post on #271 under `CONTRIBUTING.md`'s rules for forge text (item 1: it reads for someone who has never opened the repository, explains the console's terms once, and names no decision number, change name, date as narrative, deployment or count), and close it. Draft:
|
||||
|
||||
> Decided: a grant on one ladder of a product that is a tier of several ladders keeps ending the subscription's tier on all of them, and the ladders the grant is not on stay empty until it ends. (A plan ladder is a ranked list of tiers, and an organization holds one tier per ladder; a grant is an operator's record that gives an organization a product without payment.) The console delivers a product whole or not at all, so keeping the rest of the product would change how every plan is delivered, which #88 is about; putting the organization type's default plan on each empty ladder is #154's question; and asking the operator to confirm the emptied ladders was turned down, because the console warns rather than blocks, as it does when an operator sells a product that gives nothing.
|
||||
>
|
||||
> What changed is that every place an operator or a member looks now says so. The Issue grant form's warning, shown once the operator picks a product that would replace a tier a subscription pays for, names every ladder of that tier when there is more than one: "This grant would supersede a tier a subscription pays for on Duo and Wiki; its billing pauses until the grant ends." The Not delivering badge on the organization page's Billing card and in the operator's subscriptions list names the other ladders too: "Billing paused while a grant holds Wiki; nothing is delivered on Duo." On the member's Products page, the paid plan's card on each ladder the grant does not hold reads "Paused while Pro is granted on Wiki", or "Billing continues while Pro is granted on Wiki" until Stripe confirms the pause; its controls stay on the card of the ladder the grant holds. The subscription's billing pauses as it does for a tier of one ladder, because while the grant holds, the subscription delivers nothing anywhere. The rule is written in `docs/models/plan-ladders-transitions.md` beside whole-bundle supersession, and a test drives a grant over one ladder of a two-ladder subscription.
|
||||
>
|
||||
> Commit: …
|
||||
|
||||
- [ ] 10.4 The archive waits for the maintainer's go; the deltas carry the spec changes, and no Purpose needs an edit a delta cannot make.
|
||||
@@ -7,7 +7,11 @@
|
||||
// Issue grant's Product field carries the supersession warning as its one
|
||||
// hint line (grant-pauses-subscription-billing Copy row W): hidden while no
|
||||
// product is chosen, shown once the operator chooses a product marked ⚠,
|
||||
// hidden again on an unmarked one. Nothing is submitted.
|
||||
// hidden again on an unmarked one. A marked option that carries its own line
|
||||
// (data-hint, a product that supersedes a subscription tier of several
|
||||
// ladders, grant-names-every-emptied-ladder Copy row G1) makes the line read
|
||||
// that text while it is chosen, and a marked option without one makes it read
|
||||
// Copy row W again. Nothing is submitted.
|
||||
//
|
||||
// The walkthrough needs an organization whose Issue grant form offers a
|
||||
// product marked ⚠, which is an organization with a subscription on a
|
||||
@@ -19,14 +23,22 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/test/e2e/browsertest"
|
||||
)
|
||||
|
||||
const copyW = "This grant would supersede a tier a subscription pays for; its billing pauses until the grant ends."
|
||||
|
||||
const markedProductOption = `form[data-form="operator.enrollment.grant.issue"] select[name="product_id"] option[data-shows-hint]`
|
||||
|
||||
func TestGrantSupersedeHintWalkthrough(t *testing.T) {
|
||||
defer browsertest.Recover(t)
|
||||
page, base := browsertest.OpenOperator(t, "/operator/organizations")
|
||||
t.Run("an option's own hint", func(t *testing.T) {
|
||||
defer browsertest.Recover(t)
|
||||
checkOwnHint(t, page, base)
|
||||
})
|
||||
_, marked := gotoOrgCompositeWith(t, page, base, markedProductOption,
|
||||
"no org's Issue grant form offers a product marked as superseding a subscription; run test/seed-demo.sh for a fresh subject")
|
||||
// The toggle is a deferred script: a choice made before it runs meets no listener.
|
||||
@@ -37,7 +49,6 @@ func TestGrantSupersedeHintWalkthrough(t *testing.T) {
|
||||
t.Fatal("the Product select names no hint in data-hint-toggle")
|
||||
}
|
||||
hint := page.MustElement(`[id="` + *hintID + `"]`)
|
||||
const copyW = "This grant would supersede a tier a subscription pays for; its billing pauses until the grant ends."
|
||||
if got := strings.TrimSpace(hint.MustText()); got != copyW {
|
||||
t.Errorf("the Product hint reads %q, want %q", got, copyW)
|
||||
}
|
||||
@@ -61,6 +72,9 @@ func TestGrantSupersedeHintWalkthrough(t *testing.T) {
|
||||
markedValue := *marked.MustAttribute("value")
|
||||
t.Logf("after choosing a marked product: the hint is shown")
|
||||
choose(markedValue)
|
||||
if own := marked.MustAttribute("data-hint"); own == nil && strings.TrimSpace(hint.MustText()) != copyW {
|
||||
t.Errorf("after choosing a marked product, the Product hint reads %q, want %q", strings.TrimSpace(hint.MustText()), copyW)
|
||||
}
|
||||
if !shown() {
|
||||
t.Error("after choosing a marked product, the Product hint is hidden")
|
||||
}
|
||||
@@ -86,3 +100,43 @@ func TestGrantSupersedeHintWalkthrough(t *testing.T) {
|
||||
t.Error("after choosing an unmarked product, the Product select's aria-describedby names the hint")
|
||||
}
|
||||
}
|
||||
|
||||
// checkOwnHint lands on an organization whose Issue grant form offers a marked
|
||||
// product that carries its own line, chooses it and checks the line reads that
|
||||
// text, then chooses a marked option without one and checks the line reads
|
||||
// Copy row W again. It skips, naming the precondition, when the organization it
|
||||
// lands on offers no such product.
|
||||
func checkOwnHint(t *testing.T, page *rod.Page, base string) {
|
||||
t.Helper()
|
||||
_, own := gotoOrgCompositeWith(t, page, base, markedProductOption+"[data-hint]",
|
||||
"the first org whose Issue grant form offers a marked product has no product with its own hint")
|
||||
browsertest.WaitLoaded(t, page)
|
||||
sel := own.MustParent()
|
||||
hintID := sel.MustAttribute("data-hint-toggle")
|
||||
if hintID == nil || *hintID == "" {
|
||||
t.Fatal("the Product select names no hint in data-hint-toggle")
|
||||
}
|
||||
hint := page.MustElement(`[id="` + *hintID + `"]`)
|
||||
choose := func(value string) {
|
||||
sel.MustEval(`(v) => { this.value = v; this.dispatchEvent(new Event("change", {bubbles: true})); }`, value)
|
||||
}
|
||||
ownText := *own.MustAttribute("data-hint")
|
||||
choose(*own.MustAttribute("value"))
|
||||
if got := strings.TrimSpace(hint.MustText()); got != ownText {
|
||||
t.Errorf("after choosing a bundle's product, the Product hint reads %q, want %q", got, ownText)
|
||||
}
|
||||
var plainValue string
|
||||
for _, o := range sel.MustElements(`option[data-shows-hint]`) {
|
||||
if o.MustAttribute("data-hint") == nil {
|
||||
plainValue = *o.MustAttribute("value")
|
||||
break
|
||||
}
|
||||
}
|
||||
if plainValue == "" {
|
||||
t.Skip("the Product select offers no marked product without its own hint to choose after a bundle's")
|
||||
}
|
||||
choose(plainValue)
|
||||
if got := strings.TrimSpace(hint.MustText()); got != copyW {
|
||||
t.Errorf("after choosing a one-ladder product, the Product hint reads %q, want Copy W", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user