Hold a plan whose payment is due on the Products page and in the plan moves

Third section of #148, which fixes the issue's two points. A plan whose
subscription is past due, unpaid or paused is still the organization's:
the Products page shows it as the current plan and offers no Checkout
on its ladder, and the Checkout endpoint refuses one, so a second
Stripe subscription can no longer start while the first is retried.

The current card of such a plan reads "<price>/<interval>, payment due"
(or ", paused") in place of its renewal line, with "Entitlements are
suspended until the invoice is paid." (or "Entitlements are
suspended."). A past-due subscription may be a failed card or a renewal
waiting on the bank's 3-D Secure approval, so the words say what is
owed, not why. The badge tooltip that promised access until the period
end is dropped there, and the cancel confirmation names the end
("Pro ends on {date}.", or "..., then you move to {plan}.") followed by
"The unpaid invoice stays due."

The plan moves on that subscription:
- A switch, in either direction, and its preview are refused with
  "Pay the unpaid invoice before changing this plan." before any Stripe
  call, in the decision and in fulfillment, so no second invoice
  interleaves with the open one.
- Cancel plan at period end and Keep plan work as on an active plan.
  An immediate cancellation is refused: Stripe stops collecting every
  finalized invoice of the customer when a subscription is cancelled.
- Keep plan is refused once the scheduled downgrade has landed at
  Stripe, which the failed renewal usually means, so it cannot undo a
  change Stripe already bills; the page then draws no "moves to" line
  and no Keep plan. A landed downgrade is recognised by the schedule
  Stripe reports or, once Stripe has released that schedule, by the
  subscription already carrying the lower price, the test the reconcile
  uses; the second part comes from this section's review and the
  change's design says so.
- A paused plan offers no move at all and fulfillment refuses each one,
  with "A paused plan cannot be changed."; no console-made subscription
  reaches paused today.

HasActiveSubscriptionOnLadder becomes SubscriptionBacksLadder for its
wider meaning. The test kit gains ConferUnprojectedSuspended for a
subscription not yet projected. The member-plans goldens gain seven
states for these plans; the two characterization goldens of the
previous section now show the corrected page, and every other golden
gains only an empty Suspension field.

make screens: no changed screen comes from this section (the demo has
no suspended plan); the seven changed shots are the known data-only and
timing differences.

Full suite on reset databases: 4578 pass, 15 skip, none failed. make lint passes. Breaks,
each caught:
- resolveLadderSubscription keeps ExcludeSuspended (TestCancelASuspendedSubscriptionAtPeriodEnd)
- resolveLadderSubscription keeps ExcludeSuspended (TestSubscriptionBacksASuspendedLadder)
- SwitchPlan without its suspended refusal
- SwitchPlan refuses only after a Stripe read (the refusal after the first Stripe call of the downgrade)
- positionStatus never set
- the suspended refusal placed in resolveLadderSubscription instead of SwitchPlan
- KeepCurrentPlan without its landed-downgrade check
- the landed check without its price comparison
- the immediate branch without the check
- the immediate check placed after the schedule release
- SwitchPlan without the paused refusal
- KeepCurrentPlan without the paused refusal
- CancelSubscription without the paused refusal
- ErrSubscriptionSuspended returned for a paused subscription in SwitchPlan
- the suspended rule (15) removed (offer table)
- the suspended rule (15) removed (the page)
- the suspended rule applied before the held rung rule (8)
- the suspended rule applied before the free rung rule (14)
- the paused rule removed
- the paused rule placed after the held rung rule (8)
- onLadder without pos.Suspended
- positions keeps ExcludeSuspended (TestMemberPlansGolden)
- positions keeps ExcludeSuspended (TestCheckoutRefusesALadderWhosePlanIsSuspended)
- switchOffer without the suspended case
- the suspended case placed after the current-tier case
- ErrSubscriptionPaused never returned by switchOffer
- the refusal log line without subscription_status
- renderPlansAfterMove without its four cases
- renderPlansAfterMove without its row 17 case
- renderPlansAfterMove without its row 18 case
- KeepCurrentPlan without its paused refusal, seen through the page
- Suspension always payment_due (the paused branch removed)
- the payment-due price line in place of "ends"
- setCancelBodies without the appended sentence
- the sentence appended on the current card only (the loop narrowed to CanCancel)
- Cancel plan drawn on a paused plan
- Suspension payment_due only for past_due
- onLadder keeping a downgrade whose date is not after now
- onLadder dropping every downgrade of a suspended position
- setCancelBodies keeping "You keep {current} until {date}" on a payment-due plan
- setCancelBodies choosing the form of row 19 when another plan follows
- setCancelBodies choosing the form of row 20 when nothing follows
- the body of row 24 read as row 21
- the body of row 20 read as row 21
- CancelBadge keeps its Title on a suspended plan
- KeepCurrentPlan without rule 1's downgradeLanded test
- onLadder keeping a landed downgrade (the date check removed), seen through the refused Keep
- ConferUnprojectedSuspended without the suspension
- the paused rule removed (the page's move check)
This commit is contained in:
2026-10-24 02:17:28 -04:00
parent 10057f8e3a
commit 996d6eee34
54 changed files with 7345 additions and 335 deletions
+1 -1
View File
@@ -80,5 +80,5 @@ These axes are independent; most billing bugs come from reading one as another:
- **Eager vs asynchronous fulfillment** — the checkout return page reconciles immediately so the member sees their purchase land; the webhook pipeline reconciles the same subscription again later. Safe only because the reconciler is serialized and convergent (invariant 4).
- **Recorded vs happened** — an invoice's or payment's `created_at` is when the console wrote the row, which for an invoice projected late by the sweep can be days after Stripe issued it; `issued_at`, `paid_at`, `voided_at` and `failed_at` are when it happened, as Stripe reports it. Lists and the activity timeline read the second kind, never `created_at`.
- **Money representation** — integer minor units plus a per-row ISO currency code, always; cross-currency totals stay per-currency buckets and are never converted.
- **Status classification** — Stripe's many subscription statuses collapse into four behavioral buckets: entitlement-bearing (active, trialing); suspended (past due, unpaid, paused — the provisions pause delivery without ending); ended (canceled, incomplete-expired — the conferrals end, and the organization's baseline default plan is restored if nothing else covers it); ignored (incomplete — recorded without provisioning).
- **Status classification** — Stripe's many subscription statuses collapse into four behavioral buckets: entitlement-bearing (active, trialing); suspended (past due, unpaid, paused — the provisions pause delivery without ending, and the member surfaces show the plan as the current plan with its state); ended (canceled, incomplete-expired — the conferrals end, and the organization's baseline default plan is restored if nothing else covers it); ignored (incomplete — recorded without provisioning).
- **Projected data carries a recency, not a completeness guarantee** — invoices, payments, and subscription state are projections from processed webhook events (see "Sync authority per entity" above), so an unchanged or empty view is never proof that nothing happened, only that nothing has been projected yet. The operator billing surfaces name the most recent processed-event time so stale data is visibly stale; the member invoice history states the same fact in plain language, without exposing a timestamp.
+1 -1
View File
@@ -44,7 +44,7 @@ Database objects sit in the `core` schema; the baseline DDL is `internal/db/migr
| Default restoration | Resolves the org type's default ladder to its rank-0 product and confers it — plus the floor guard that restores only when the pool has no live occupancy on any ladder | `internal/entitlements/reapply_defaults.go` |
| Stripe-driven convergence | Translates a subscription's remote state into confer / end / suspend-resume calls (see the Payments and Billing card) | `internal/fulfillment/reconcile.go` |
| Member plan moves | Switch, cancel, and the commitment gating layered above conferral | `internal/fulfillment/plan_change.go` |
| Member position | The organization's position on each ladder, read from its default pool in one query (`ListLadderPositionsByPool`, `internal/entitlements/queries/pool_provision_ladders.sql`); the Products page, Checkout, the switch and fulfillment's plan changes read it. The query returns every live position with the position's and the subscription's status, active rows first, and "held" is what each reader makes of it | `internal/entitlements/queries/pool_provision_ladders.sql` |
| Member position | The organization's position on each ladder, read from its default pool in one query (`ListLadderPositionsByPool`, `internal/entitlements/queries/pool_provision_ladders.sql`); the Products page, Checkout, the switch and fulfillment's plan changes read it. The query returns every live position with the position's and the subscription's status, active rows first, and "held" is what each reader makes of it. The Products page, Checkout and the switch hold a suspended position as the current plan, while it delivers nothing | `internal/entitlements/queries/pool_provision_ladders.sql` |
| Member move offer | `plans.Offer` decides which move an organization is offered toward a price on a ladder, `plans.Across` combines a product's ladders, `plans.OffLadder` decides a product on none; the Products page draws from them and Checkout, the switch and its preview refuse anything else | `internal/plans/offer.go` |
| Operator ladder management | Ladder and tier CRUD, tier add, tier delete, tier removal and reorder. Each of the four tier changes is a plan act: one function in `internal/entitlements` runs it in one transaction under the exclusive materialization rendezvous and records it in the act record, a header and a detail row (the [Acts card](acts.md)). A reorder that gives a ladder a new rank-0 product also reaches every org type that defaults to the ladder: the act's listing then writes one obligation for each organization of those types that has a default pool and a record for each that has none, and the drain moves each pool onto the new rank-0 product (the Acts card describes both). The removal preview shows what happens to pools sitting on affected rank-0 tiers. Removing the last tier of a ladder an org type defaults to is refused on both paths, the delete of a tier no one holds (`DeleteTier`) and the removal of a held tier (`RemoveTier`), because both call `LastTierRefusal` and signups land on that ladder's entry tier | `internal/server/operator_plan_ladders.go`, `internal/entitlements/ladder_acts.go`, `internal/entitlements/tier_reorder.go`, `internal/entitlements/plan_act_runner.go` |
| Operator enrollment | Issue, revoke, and extend grants; revoke ends positions then restores the guarded default | `internal/server/operator_enrollment.go` |
+1
View File
@@ -210,6 +210,7 @@ A member moves between the paid tiers of a plan ladder on the Products page, and
- **Downgrade (to a lower tier).** The change waits for renewal. The console attaches a Stripe subscription schedule whose second phase carries the lower price, with no proration, so the renewal invoice bills the lower tier and no credit arises. The schedule's metadata carries `scheduled_from_price_id`, the console price the member was on when the downgrade was scheduled.
- **Cancellation.** Every paid plan offers **Cancel plan** on its card. Access continues until the end of the paid period (`cancel_at_period_end`), after which the organization moves to its type's default plan, when one is configured, or leaves the ladder.
- **Taking a scheduled move back.** While a downgrade or a cancellation is scheduled, the plan's card offers **Keep plan**, which releases the schedule or clears `cancel_at_period_end` at no charge. While a downgrade is scheduled, **Cancel plan** follows it and replaces the downgrade; while a cancellation is scheduled, **Keep plan** is the only control.
- **A plan whose payment is due.** A subscription Stripe holds as `past_due` or `unpaid` keeps its card on the Products page, shown as the current plan with its state, while its entitlements are suspended. No switch is offered in either direction and a posted one is refused before any Stripe call, because a switch would invoice while the failed invoice is still open. **Keep plan** and **Cancel plan** at the period end work as on an active plan. A posted immediate cancellation is refused, because Stripe's immediate cancellation stops automatic collection of the customer's finalized invoices, and a **Keep plan** over a downgrade that Stripe has already applied is refused, because there is nothing left to take back. The unpaid invoice stays due after a cancellation. A paused subscription shows its state and is refused every act.
A restricted key needs permission for the calls these moves make, beyond those the console already uses: `POST /v1/subscription_schedules`, `POST /v1/subscription_schedules/{id}`, `POST /v1/subscription_schedules/{id}/release` and `POST /v1/invoices/{id}/void`. Stripe names the missing permission in the error a key without it receives.
@@ -52,7 +52,12 @@
price, "Included" when it carries no price at all (the free
rung), or nothing when its price exists but isn't synced yet
or is one-time (the purchasability gate already disables its
move control with a visible reason below). */}}
move control with a visible reason below). A suspended plan
keeps its card: where the price line would read "renews" it
reads "payment due" or "paused" (a scheduled cancellation's
"ends" and a pending downgrade's "moves to" stay), and one
fact line under it says the entitlements are suspended. A
paused card draws no control. */}}
{{ if eq .Relation "current" }}
{{ if .BillingPending }}
<p class="text-muted small mb-2">Billing details are still being processed.</p>
@@ -60,9 +65,16 @@
<p class="text-muted small mb-2">{{ .BillingPriceText }}, moves to {{ .MovesTo }} on {{ .MovesOn }}</p>
{{ else if .EndsOn }}
<p class="text-muted small mb-2">{{ .BillingPriceText }}, ends {{ .EndsOn }}</p>
{{ else if and .BillingPriceText (eq .Suspension "payment_due") }}
<p class="text-muted small mb-2">{{ .BillingPriceText }}, payment due</p>
{{ else if and .BillingPriceText (eq .Suspension "paused") }}
<p class="text-muted small mb-2">{{ .BillingPriceText }}, paused</p>
{{ else if .BillingPriceText }}
<p class="text-muted small mb-2">{{ .BillingPriceText }}, renews {{ .BillingRenewsOn }}</p>
{{ end }}
{{- if .Suspension }}
<p class="small text-danger mb-2">{{ if eq .Suspension "paused" }}Entitlements are suspended.{{ else }}Entitlements are suspended until the invoice is paid.{{ end }}</p>
{{- end }}
{{ else if .Held }}
<p class="text-muted small mb-2">Granted</p>
{{ else if .NoPriceTier }}
+109 -39
View File
@@ -6,7 +6,7 @@ package fulfillment
// plan_change.go implements the member-initiated plan moves that ride on the
// reconcile convergence: a paid->paid switch (in-place Stripe item-price modify
// with proration) and a paid->free cancellation (immediate or at period end).
// Both resolve the member's active subscription on a ladder, perform the Stripe
// Both resolve the member's live (active or suspended) subscription on a ladder, perform the Stripe
// mutation, and then ReconcileSubscription to converge core records and the
// pool's ladder attachment through the transition primitive.
//
@@ -34,11 +34,36 @@ import (
)
// ErrNoActivePaidSubscription is returned when a switch/cancel is requested on a
// ladder where the member holds no active subscription-backed rung (e.g. they
// ladder where the member holds no live (active or suspended) subscription-backed rung (e.g. they
// are on a free/default grant). The free/default -> first paid move is Checkout's
// job, not this path's.
var ErrNoActivePaidSubscription = errors.New("no active paid subscription on this ladder")
// ErrSubscriptionSuspended is returned when a switch is requested on a
// subscription whose position is suspended. A suspended subscription is kept
// or cancelled and never switched, because a switch would invoice while the
// failed invoice is still open.
var ErrSubscriptionSuspended = errors.New("switch: the subscription's position is suspended")
// ErrSubscriptionPaused is returned when a switch, Keep plan or a
// cancellation is requested on a paused subscription. A paused subscription
// is refused all three because what a cancellation does to one is
// unverified: its period end can already be past, and the sweeper would end
// it at once.
var ErrSubscriptionPaused = errors.New("the subscription is paused")
// ErrScheduledMoveApplied is returned when Keep plan finds that the
// scheduled downgrade it would take back has already been applied by Stripe,
// so there is nothing left to release.
var ErrScheduledMoveApplied = errors.New("keep: the scheduled move has already been applied")
// ErrImmediateCancelSuspended is returned when an immediate cancellation is
// requested on a suspended subscription. Stripe's immediate cancellation
// stops automatic collection of the customer's finalized invoices, which
// abandons the unpaid invoice and reaches the customer's other
// subscriptions.
var ErrImmediateCancelSuspended = errors.New("cancel: a suspended subscription cannot be cancelled immediately")
// ErrSameTier is returned when a switch targets the tier the member already holds.
var ErrSameTier = errors.New("target tier is the current tier")
@@ -111,14 +136,19 @@ const (
CancelImmediate CancelTiming = "immediate"
)
// ladderSubscription is the resolved subscription backing a member's active rung
// on a specific ladder.
// ladderSubscription is the resolved subscription backing a member's live rung
// (active or suspended) on a specific ladder.
type ladderSubscription struct {
coreSubID string
stripeSubID string
currentProductID string
stripeItemID string // Stripe si_ id of the item on this ladder
sub billing.Subscription
// positionStatus and subscriptionStatus are the row's position_status
// and subscription_status: the position is suspended when the first reads
// "suspended", and the second says whether the subscription is paused.
positionStatus string
subscriptionStatus string
coreSubID string
stripeSubID string
currentProductID string
stripeItemID string // Stripe si_ id of the item on this ladder
sub billing.Subscription
// coreItemID and the two mapping stamps are what a caller needs to ask
// whether the ids above can be reached under the current API key
// (stripe-environment-stamp D5). Resolving already reads both mapping
@@ -142,16 +172,20 @@ func logUnreachableMapping(logger *slog.Logger, msg, rowID, stripeID string, liv
slog.String("key_mode", keyMode))
}
// SwitchPlan moves the member's active subscription on ladderID to the target
// SwitchPlan moves the member's live (active or suspended) subscription on ladderID to the target
// tier's price, then reconciles (plan-switch-timing). An upgrade is charged
// now and applies once paid: prorationDate is the moment the member's
// preview prorated at, and the update sends it back so the charge equals
// the figure shown. A downgrade takes effect at renewal through a Stripe
// subscription schedule, unless a live commitment with
// early_termination_policy='block' defers it to the commitment boundary.
// Upgrades are never gated by a commitment. keyMode is the mode of the key
// the process runs under; it is checked before the Stripe call and handed to
// the reconcile that follows it.
// Upgrades are never gated by a commitment. A suspended position is refused
// with ErrSubscriptionSuspended, and a paused subscription with
// ErrSubscriptionPaused, directly after the subscription is resolved and
// before the target price, the mappings or the commitment are read, so
// neither a Stripe call nor a deferred scheduled change is made. keyMode is
// the mode of the key the process runs under; it is checked before the
// Stripe call and handed to the reconcile that follows it.
func SwitchPlan(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, orgID, ladderID, targetPriceID string, prorationDate time.Time, reason string) error {
if logger == nil {
logger = slog.Default()
@@ -163,6 +197,12 @@ func SwitchPlan(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode st
if err != nil {
return err
}
if ls.subscriptionStatus == "paused" {
return ErrSubscriptionPaused
}
if ls.positionStatus == "suspended" {
return ErrSubscriptionSuspended
}
targetPrice, err := billingQ.GetPrice(ctx, targetPriceID)
if err != nil {
@@ -414,13 +454,18 @@ func downgradeAtRenewal(ctx context.Context, db *sql.DB, logger *slog.Logger, ke
return ReconcileSubscription(ctx, db, logger, keyMode, ls.stripeSubID, reason)
}
// KeepCurrentPlan drops whatever move is scheduled on the member's active
// subscription on ladderID (plan-switch-timing D3): it releases the
// KeepCurrentPlan drops whatever move is scheduled on the member's live (active or
// suspended) subscription on ladderID (plan-switch-timing D3): it releases the
// downgrade schedule the console attached, clears a cancellation armed at
// period end, supersedes the active scheduled changes, and reconciles.
// Nothing is charged. With nothing scheduled it makes no Stripe call.
// keyMode is the mode of the key the process runs under; it is handed to the
// reconcile.
// reconcile. It acts on a suspended subscription as on an active one, and the
// reconcile after it keeps the provisions suspended. It refuses a paused
// subscription with ErrSubscriptionPaused, and a suspended subscription whose
// scheduled downgrade Stripe has already applied with ErrScheduledMoveApplied,
// before it releases a schedule or supersedes a row; the active path is
// unchanged.
func KeepCurrentPlan(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, orgID, ladderID, reason string) error {
if logger == nil {
logger = slog.Default()
@@ -430,6 +475,9 @@ func KeepCurrentPlan(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMo
if err != nil {
return err
}
if ls.subscriptionStatus == "paused" {
return ErrSubscriptionPaused
}
active, err := billingQ.ListActiveScheduledChangesBySubscription(ctx, ls.coreSubID)
if err != nil {
return fmt.Errorf("keep: list scheduled changes: %w", err)
@@ -442,6 +490,12 @@ func KeepCurrentPlan(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMo
if err != nil {
return fmt.Errorf("keep: read subscription: %w", err)
}
if ls.positionStatus == "suspended" {
prices := currentPrices(ctx, internalstripe.New(db), current)
if scheduleIsSpent(current, prices) || downgradeLanded(active, prices) {
return ErrScheduledMoveApplied
}
}
if current.Schedule != nil && current.Schedule.Metadata[scheduledFromPriceKey] != "" {
if _, err := subscriptionschedule.Release(current.Schedule.ID, nil); err != nil {
return fmt.Errorf("keep: release the scheduled downgrade: %w", err)
@@ -474,7 +528,7 @@ type SwitchPreview struct {
EffectiveAt time.Time
}
// PreviewSwitch states what switching the member's active rung on ladderID to
// PreviewSwitch states what switching the member's live (active or suspended) rung on ladderID to
// targetPriceID costs and when. An upgrade's figure comes from Stripe's
// invoice preview with proration_behavior=always_invoice, prorated at the
// moment of the request; it is best-effort, so any failure returns an
@@ -546,11 +600,16 @@ func PreviewSwitch(ctx context.Context, db *sql.DB, logger *slog.Logger, orgID,
return SwitchPreview{Available: true, NetAmount: inv.AmountDue, Currency: string(inv.Currency), ProrationDate: moment}
}
// CancelSubscription cancels the member's active subscription on ladderID. timing
// CancelSubscription cancels the member's live (active or suspended) subscription on ladderID. timing
// selects immediate vs period-end (default period-end). A committed cancellation
// under 'block' defers to the commitment boundary; under 'fee' it is refused;
// under 'allow'/evergreen it follows the requested timing. keyMode is the
// mode of the key the process runs under; it is handed to the reconcile.
// It acts on a suspended subscription as on an active one, and the reconcile
// after it keeps the provisions suspended. It refuses a paused subscription
// with ErrSubscriptionPaused and, on a suspended subscription, CancelImmediate
// with ErrImmediateCancelSuspended, each before it releases a schedule or
// makes a Stripe call.
func CancelSubscription(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, orgID, ladderID string, timing CancelTiming, reason string) error {
if logger == nil {
logger = slog.Default()
@@ -561,6 +620,12 @@ func CancelSubscription(ctx context.Context, db *sql.DB, logger *slog.Logger, ke
if err != nil {
return err
}
if ls.subscriptionStatus == "paused" {
return ErrSubscriptionPaused
}
if timing == CancelImmediate && ls.positionStatus == "suspended" {
return ErrImmediateCancelSuspended
}
// A cancellation is always an early termination relative to a commitment.
deferToBoundary, boundary, derr := commitmentDisposition(ls.sub)
@@ -677,15 +742,16 @@ func isDowngradeOnLadder(ctx context.Context, billingQ *billing.Queries, ladderI
return tgt < cur, nil
}
// HasActiveSubscriptionOnLadder reports whether orgID already holds an active
// subscription-backed rung on ladderID. The switch preview uses this to refuse,
// with the switch's own answer, a ladder no subscription pays for rather than
// quote a charge nothing will make. Checkout once used it against a second
// concurrent subscription on a ladder (finding #10); that protection is now
// the offer decision (plans.Offer and plans.Across, asked in
// SubscriptionBacksLadder reports whether a subscription backs orgID's
// position on ladderID, active or suspended. The switch preview reads it
// after the offer decision, which refuses a suspended position first, to
// refuse, with the switch's own answer, a ladder no subscription pays for
// rather than quote a charge nothing will make. Checkout once used it against
// a second concurrent subscription on a ladder (finding #10); that protection
// is now the offer decision (plans.Offer and plans.Across, asked in
// internal/server/billing.go). A grant- or purchase-backed rung (no
// subscription) does not count.
func HasActiveSubscriptionOnLadder(ctx context.Context, db *sql.DB, orgID, ladderID string) (bool, error) {
func SubscriptionBacksLadder(ctx context.Context, db *sql.DB, orgID, ladderID string) (bool, error) {
_, err := resolveLadderSubscription(ctx, db, orgID, ladderID)
if err == nil {
return true, nil
@@ -696,9 +762,11 @@ func HasActiveSubscriptionOnLadder(ctx context.Context, db *sql.DB, orgID, ladde
return false, err
}
// resolveLadderSubscription finds the active subscription-backed rung on ladderID
// for the member's org and resolves the Stripe subscription + item ids needed to
// mutate it.
// resolveLadderSubscription finds the subscription behind a live position
// (active or suspended) on ladderID for the member's org and resolves the
// Stripe subscription + item ids needed to mutate it. The position's and the
// subscription's status come back in the result; the callers decide what a
// suspended position means to them.
func resolveLadderSubscription(ctx context.Context, db *sql.DB, orgID, ladderID string) (ladderSubscription, error) {
var ls ladderSubscription
billingQ := billing.New(db)
@@ -720,7 +788,7 @@ func resolveLadderSubscription(ctx context.Context, db *sql.DB, orgID, ladderID
if err != nil {
return ls, ErrNoActivePaidSubscription
}
pos, ok := entitlements.PositionsByLadder(entitlements.ExcludeSuspended(rows))[ladderID]
pos, ok := entitlements.PositionsByLadder(rows)[ladderID]
if !ok {
return ls, ErrNoActivePaidSubscription
}
@@ -765,16 +833,18 @@ func resolveLadderSubscription(ctx context.Context, db *sql.DB, orgID, ladderID
}
ls = ladderSubscription{
coreSubID: coreSubID,
stripeSubID: subMapping.StripeSubscriptionID.String,
currentProductID: currentProductID,
stripeItemID: itemMapping.StripeSubscriptionItemID.String,
sub: sub,
coreItemID: coreItemID,
subLivemode: subMapping.Livemode,
subSyncStatus: subMapping.SyncStatus,
itemLivemode: itemMapping.Livemode,
itemSyncStatus: itemMapping.SyncStatus,
positionStatus: pos.PositionStatus,
subscriptionStatus: pos.SubscriptionStatus.String,
coreSubID: coreSubID,
stripeSubID: subMapping.StripeSubscriptionID.String,
currentProductID: currentProductID,
stripeItemID: itemMapping.StripeSubscriptionItemID.String,
sub: sub,
coreItemID: coreItemID,
subLivemode: subMapping.Livemode,
subSyncStatus: subMapping.SyncStatus,
itemLivemode: itemMapping.Livemode,
itemSyncStatus: itemMapping.SyncStatus,
}
return ls, nil
}
+35 -8
View File
@@ -867,8 +867,7 @@ func applyLandedDowngrades(
) (map[string]bool, error) {
closed := map[string]bool{}
for _, r := range rows {
if r.ChangeType != "plan_switch" || r.EffectiveTrigger != "period_end" ||
!r.TargetPriceID.Valid || !current[r.TargetPriceID.UUID.String()] {
if !downgradeRowLanded(r, current) {
continue
}
if _, err := billingQ.MarkScheduledChangeApplied(ctx, r.ScheduledChangeID); err != nil && !errors.Is(err, sql.ErrNoRows) {
@@ -879,7 +878,39 @@ func applyLandedDowngrades(
return closed, nil
}
// findSpentSchedule is rule 4's test: a schedule the console attached has
// scheduleIsSpent is rule 4's test, shared with KeepCurrentPlan: the
// subscription carries a schedule the console attached (one with the
// scheduled_from_price_id metadata) and no longer carries the price the
// schedule was made from, current being the console price ids it carries now.
func scheduleIsSpent(sub *stripe.Subscription, current map[string]bool) bool {
if sub.Schedule == nil {
return false
}
from := sub.Schedule.Metadata[scheduledFromPriceKey]
return from != "" && !current[from]
}
// downgradeLanded is rule 1's test, shared with KeepCurrentPlan: an active
// period-end plan_switch row whose target price is one the subscription
// carries now (current being the console price ids it carries), so Stripe
// has applied the downgrade. It holds after the schedule released itself,
// when the subscription carries no schedule any more.
func downgradeLanded(rows []billing.SubscriptionScheduledChange, current map[string]bool) bool {
for _, r := range rows {
if downgradeRowLanded(r, current) {
return true
}
}
return false
}
// downgradeRowLanded is rule 1's test on one row.
func downgradeRowLanded(r billing.SubscriptionScheduledChange, current map[string]bool) bool {
return r.ChangeType == "plan_switch" && r.EffectiveTrigger == "period_end" &&
r.TargetPriceID.Valid && current[r.TargetPriceID.UUID.String()]
}
// findSpentSchedule applies rule 4 through scheduleIsSpent: a schedule the console attached has
// nothing left to do once the subscription no longer carries the price it
// was made from, either because its downgrade landed or because the member
// moved since. Left attached, it would move an upgraded member back down at
@@ -894,11 +925,7 @@ func findSpentSchedule(
rows []billing.SubscriptionScheduledChange,
closed map[string]bool,
) spentSchedule {
if sub.Schedule == nil {
return spentSchedule{}
}
from := sub.Schedule.Metadata[scheduledFromPriceKey]
if from == "" || current[from] {
if !scheduleIsSpent(sub, current) {
return spentSchedule{}
}
spent := spentSchedule{id: sub.Schedule.ID}
@@ -0,0 +1,334 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package fulfillment_test
// suspended_subscription_test.go pins what the plan acts do with a
// subscription the reconcile has suspended (suspended-plans-stay-held D5,
// D6): a switch is refused, Keep plan and the period-end cancellation work,
// a posted immediate cancellation and a Keep plan over a downgrade Stripe
// already applied are refused, and a paused subscription is refused every
// act. Each state is built from the Stripe mock's subscription held as
// past_due or paused and reconciled, so the subscription row and the
// provisions carry the pair the reconcile writes.
import (
"context"
"database/sql"
"errors"
"strings"
"testing"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/fulfillment"
"git.coopcloud.tech/wiki-cafe/member-console/internal/stripetest"
stripe "github.com/stripe/stripe-go/v81"
)
// suspendedOnTier2 is onTier2's state with the mock's subscription then held
// as status and reconciled again: the subscription row reads status and its
// provisions read suspended, the pair the reconcile writes.
func suspendedOnTier2(t *testing.T, status stripe.SubscriptionStatus) (*sql.DB, fixture, *stripetest.MockBackend, string, string, string) {
t.Helper()
database, fx, mock, prod2, corePrice2, stripePrice2 := onTier2(t)
holdAs(t, database, fx, mock, status)
return database, fx, mock, prod2, corePrice2, stripePrice2
}
// holdAs makes the mock hold the subscription as status, reconciles it, and
// checks that the subscription row reads status and the provisions read
// suspended.
func holdAs(t *testing.T, database *sql.DB, fx fixture, mock *stripetest.MockBackend, status stripe.SubscriptionStatus) {
t.Helper()
mock.Sub.Status = status
if err := fulfillment.ReconcileSubscription(context.Background(), database, discardLogger(), stripetest.KeyMode(), fx.stripeSubID, "test:suspend"); err != nil {
t.Fatalf("reconcile as %s: %v", status, err)
}
core := coreSubID(t, database, fx.stripeSubID)
if got := subscriptionStatus(t, database, core); got != string(status) {
t.Fatalf("the subscription row reads %q, want %q", got, status)
}
if got := provisionStatus(t, database, core); got != "suspended" {
t.Fatalf("the provisions read %q, want suspended", got)
}
}
func subscriptionStatus(t *testing.T, database *sql.DB, coreSub string) string {
t.Helper()
var s string
if err := database.QueryRowContext(context.Background(),
`SELECT status FROM core.subscriptions WHERE subscription_id = $1`, coreSub).Scan(&s); err != nil {
t.Fatalf("read the status of subscription %s: %v", coreSub, err)
}
return s
}
// provisionStatus is the distinct statuses of the subscription's provisions
// that have not ended, joined by commas.
func provisionStatus(t *testing.T, database *sql.DB, coreSub string) string {
t.Helper()
var s string
if err := database.QueryRowContext(context.Background(),
`SELECT COALESCE(string_agg(DISTINCT status, ','), '') FROM core.pool_provisions
WHERE subscription_id = $1 AND status <> 'ended'`, coreSub).Scan(&s); err != nil {
t.Fatalf("read the provisions of subscription %s: %v", coreSub, err)
}
return s
}
func scheduledChangeCount(t *testing.T, database *sql.DB, coreSub string) int {
t.Helper()
return scalarInt(t, database, `SELECT count(*) FROM core.subscription_scheduled_changes WHERE subscription_id = $1`, coreSub)
}
// callsSince is the requests the mock received after mark, one line each.
func callsSince(mock *stripetest.MockBackend, mark int) []string {
var lines []string
for _, c := range mock.Calls[mark:] {
lines = append(lines, stripetest.Line(c))
}
return lines
}
// switchRefusal is one direction of a switch on a suspended subscription.
type switchRefusal struct {
name string
// target adds what the row needs and returns the price to switch to.
target func(t *testing.T, database *sql.DB, fx fixture) string
}
func TestSwitchRefusesASuspendedSubscription(t *testing.T) {
for _, row := range []switchRefusal{
{"an upgrade to a third tier", func(t *testing.T, database *sql.DB, fx fixture) string {
_, corePrice3, _ := addTier(t, database, fx, "top", 6000)
return corePrice3
}},
{"a downgrade to tier 1", func(_ *testing.T, _ *sql.DB, fx fixture) string { return fx.priceID }},
} {
t.Run(row.name, func(t *testing.T) { checkSwitchRefused(t, row) })
}
}
func checkSwitchRefused(t *testing.T, row switchRefusal) {
database, fx, mock, _, _, _ := suspendedOnTier2(t, stripe.SubscriptionStatusPastDue)
target := row.target(t, database, fx)
mark := len(mock.Calls)
err := fulfillment.SwitchPlan(context.Background(), database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, target, time.Now(), "member:switch")
if !errors.Is(err, fulfillment.ErrSubscriptionSuspended) {
t.Errorf("SwitchPlan on a suspended subscription returned %v, want ErrSubscriptionSuspended", err)
}
if calls := callsSince(mock, mark); len(calls) != 0 {
t.Errorf("the switch called Stripe: %v", calls)
}
core := coreSubID(t, database, fx.stripeSubID)
if n := scheduledChangeCount(t, database, core); n != 0 {
t.Errorf("the switch wrote %d scheduled changes, want none", n)
}
}
func TestCancelASuspendedSubscriptionAtPeriodEnd(t *testing.T) {
database, fx, mock, _, _, _ := suspendedOnTier2(t, stripe.SubscriptionStatusPastDue)
err := fulfillment.CancelSubscription(context.Background(), database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel")
if err != nil {
t.Errorf("CancelSubscription on a suspended subscription returned %v, want nil", err)
}
var flagged bool
for _, c := range mock.CallsTo("POST", "/v1/subscriptions/"+fx.stripeSubID) {
flagged = flagged || c.Form.Get("cancel_at_period_end") == "true"
}
if !flagged {
t.Error("the cancellation sent no cancel_at_period_end=true")
}
core := coreSubID(t, database, fx.stripeSubID)
if n := rowCount(t, database, core, "cancellation", "period_end", "scheduled"); n != 1 {
t.Errorf("scheduled cancellations = %d, want 1", n)
}
if got := provisionStatus(t, database, core); got != "suspended" {
t.Errorf("the provisions read %q, want suspended", got)
}
}
func TestKeepASuspendedSubscription(t *testing.T) {
database, fx, mock, _, _, _ := suspendedOnTier2(t, stripe.SubscriptionStatusPastDue)
ctx := context.Background()
if err := fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel"); err != nil {
t.Fatalf("cancel: %v", err)
}
mark := len(mock.Calls)
if err := fulfillment.KeepCurrentPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, "member:keep"); err != nil {
t.Errorf("KeepCurrentPlan on a suspended subscription returned %v, want nil", err)
}
var cleared bool
for _, c := range mock.Calls[mark:] {
cleared = cleared || (c.Method == "POST" && c.Path == "/v1/subscriptions/"+fx.stripeSubID && c.Form.Get("cancel_at_period_end") == "false")
}
if !cleared {
t.Error("the keep sent no cancel_at_period_end=false")
}
core := coreSubID(t, database, fx.stripeSubID)
if n := rowCount(t, database, core, "cancellation", "period_end", "superseded"); n != 1 {
t.Errorf("superseded cancellations = %d, want 1", n)
}
}
func TestKeepReleasesAPendingDowngradeOnASuspendedSubscription(t *testing.T) {
database, fx, mock, _, _, _ := onTier2(t)
ctx := context.Background()
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
t.Fatalf("downgrade: %v", err)
}
holdAs(t, database, fx, mock, stripe.SubscriptionStatusPastDue)
if err := fulfillment.KeepCurrentPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, "member:keep"); err != nil {
t.Errorf("KeepCurrentPlan on a pending downgrade of a suspended subscription returned %v, want nil", err)
}
if !mock.Sent("POST", "/v1/subscription_schedules/"+mockSchedule+"/release") {
t.Error("the keep sent no release of the schedule")
}
core := coreSubID(t, database, fx.stripeSubID)
if n := rowCount(t, database, core, "plan_switch", "period_end", "superseded"); n != 1 {
t.Errorf("the plan_switch row reads %s, want superseded", scheduledChangeStatus(t, database, core))
}
if got := provisionStatus(t, database, core); got != "suspended" {
t.Errorf("the provisions read %q, want suspended", got)
}
}
func scheduledChangeStatus(t *testing.T, database *sql.DB, coreSub string) string {
t.Helper()
var s string
if err := database.QueryRowContext(context.Background(),
`SELECT COALESCE(string_agg(status, ','), 'none') FROM core.subscription_scheduled_changes
WHERE subscription_id = $1 AND change_type = 'plan_switch'`, coreSub).Scan(&s); err != nil {
t.Fatalf("read the plan_switch rows of subscription %s: %v", coreSub, err)
}
return s
}
func TestKeepRefusesADowngradeThatLanded(t *testing.T) {
database, fx, mock, _, _, _ := onTier2(t)
ctx := context.Background()
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
t.Fatalf("downgrade: %v", err)
}
// Stripe entered the schedule's second phase: the subscription carries the
// lower tier's price, the schedule is still attached, and the renewal's
// invoice is unpaid.
landed := fx.activeSub(fx.planItem())
landed.Schedule = mock.Sub.Schedule
mock.Sub = landed
holdAs(t, database, fx, mock, stripe.SubscriptionStatusPastDue)
mark := len(mock.Calls)
err := fulfillment.KeepCurrentPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, "member:keep")
if !errors.Is(err, fulfillment.ErrScheduledMoveApplied) {
t.Errorf("KeepCurrentPlan on a landed downgrade returned %v, want ErrScheduledMoveApplied", err)
}
for _, c := range mock.Calls[mark:] {
if c.Method != "GET" {
t.Errorf("the keep wrote to Stripe: %s", stripetest.Line(c))
}
}
core := coreSubID(t, database, fx.stripeSubID)
if n := rowCount(t, database, core, "plan_switch", "period_end", "scheduled"); n != 1 {
t.Errorf("the plan_switch row reads %s, want scheduled", scheduledChangeStatus(t, database, core))
}
}
func TestKeepRefusesADowngradeThatLandedAfterTheScheduleReleased(t *testing.T) {
database, fx, mock, _, _, _ := onTier2(t)
ctx := context.Background()
if err := fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fx.priceID, time.Time{}, "member:switch"); err != nil {
t.Fatalf("downgrade: %v", err)
}
// Stripe entered the schedule's second phase and the schedule released
// itself: the subscription carries the lower tier's price, no schedule is
// attached, and the renewal's invoice is unpaid.
landed := fx.activeSub(fx.planItem())
landed.Schedule = nil
mock.Sub = landed
holdAs(t, database, fx, mock, stripe.SubscriptionStatusPastDue)
mark := len(mock.Calls)
err := fulfillment.KeepCurrentPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, "member:keep")
if !errors.Is(err, fulfillment.ErrScheduledMoveApplied) {
t.Errorf("KeepCurrentPlan on a landed downgrade returned %v, want ErrScheduledMoveApplied", err)
}
for _, c := range mock.Calls[mark:] {
if c.Method != "GET" {
t.Errorf("the keep wrote to Stripe: %s", stripetest.Line(c))
}
}
core := coreSubID(t, database, fx.stripeSubID)
if n := rowCount(t, database, core, "plan_switch", "period_end", "scheduled"); n != 1 {
t.Errorf("the plan_switch row reads %s, want scheduled", scheduledChangeStatus(t, database, core))
}
}
func TestCancelImmediatelyRefusesASuspendedSubscription(t *testing.T) {
database, fx, mock, _, _, _ := suspendedOnTier2(t, stripe.SubscriptionStatusPastDue)
mark := len(mock.Calls)
err := fulfillment.CancelSubscription(context.Background(), database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fulfillment.CancelImmediate, "member:cancel")
if !errors.Is(err, fulfillment.ErrImmediateCancelSuspended) {
t.Errorf("CancelSubscription(immediate) on a suspended subscription returned %v, want ErrImmediateCancelSuspended", err)
}
if calls := callsSince(mock, mark); len(calls) != 0 {
t.Errorf("the cancellation called Stripe: %v", calls)
}
core := coreSubID(t, database, fx.stripeSubID)
if n := scheduledChangeCount(t, database, core); n != 0 {
t.Errorf("the cancellation wrote %d scheduled changes, want none", n)
}
}
// pausedAct is one plan act tried on a paused subscription.
type pausedAct struct {
name string
run func(t *testing.T, database *sql.DB, fx fixture) error
}
func TestPausedSubscriptionRefusesEveryAct(t *testing.T) {
ctx := context.Background()
for _, act := range []pausedAct{
{"SwitchPlan", func(_ *testing.T, database *sql.DB, fx fixture) error {
return fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fx.priceID, time.Now(), "member:switch")
}},
{"KeepCurrentPlan", func(_ *testing.T, database *sql.DB, fx fixture) error {
return fulfillment.KeepCurrentPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, "member:keep")
}},
{"CancelSubscription", func(_ *testing.T, database *sql.DB, fx fixture) error {
return fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel")
}},
} {
t.Run(act.name, func(t *testing.T) { checkPausedRefusal(t, act) })
}
}
func checkPausedRefusal(t *testing.T, act pausedAct) {
database, fx, mock, _, _, _ := suspendedOnTier2(t, stripe.SubscriptionStatusPaused)
mark := len(mock.Calls)
err := act.run(t, database, fx)
if !errors.Is(err, fulfillment.ErrSubscriptionPaused) {
t.Errorf("%s on a paused subscription returned %v, want ErrSubscriptionPaused", act.name, err)
}
if calls := callsSince(mock, mark); len(calls) != 0 {
t.Errorf("%s on a paused subscription called Stripe: %s", act.name, strings.Join(calls, "; "))
}
}
func TestSubscriptionBacksASuspendedLadder(t *testing.T) {
database, fx, _, _, _, _ := suspendedOnTier2(t, stripe.SubscriptionStatusPastDue)
backs, err := fulfillment.SubscriptionBacksLadder(context.Background(), database, fx.orgID, fx.ladderID)
if err != nil {
t.Fatalf("SubscriptionBacksLadder: %v", err)
}
if !backs {
t.Error("SubscriptionBacksLadder = false for a suspended position")
}
}
+38 -14
View File
@@ -40,6 +40,13 @@ type Position struct {
// Follows is the product the prediction puts on this ladder, "" when it
// puts nothing there.
Follows string
// Suspended is true when the position is suspended: the ladder row reads
// suspended, because the subscription's payment is due or it is paused. A
// suspended position is still the current one; it delivers nothing.
Suspended bool
// Paused is true when the position is suspended and its subscription is
// paused. Paused implies Suspended.
Paused bool
}
// Target is the product a move would land on and the price it would use.
@@ -101,6 +108,8 @@ const (
OneTime Reason = "the price is one-time"
Unpurchasable Reason = "the price has no Stripe price the key can reach"
OtherLadder Reason = "another ladder of the product forbids it"
Suspended Reason = "the position is suspended"
Paused Reason = "the position is paused"
)
// Relation is the move relative to the current rung on the ladder. Offer sets
@@ -154,24 +163,33 @@ func (p Position) rankOf(productID string) (int32, bool) {
// 1. The target is not a tier of the ladder: none.
// 2. The organization holds a product that is not a tier of the ladder: none.
// 3. The target is the current product: none.
// 4. A subscription pays, no cancellation is scheduled, and the target is the
// 4. The position is paused: none, with Paused.
// 5. A subscription pays, no cancellation is scheduled, and the target is the
// scheduled downgrade's and not the held rung: none.
// 5. The target is the held rung and the cancellation is scheduled: none.
// 6. The target is the held rung above the current rung: none.
// 7. The target is the held rung: Cancel. Cancelling buys nothing, so the
// 6. The target is the held rung and the cancellation is scheduled: none.
// 7. The target is the held rung above the current rung: none.
// 8. The target is the held rung: Cancel. Cancelling buys nothing, so the
// price rule does not apply.
// 8. The target ranks below both the held rung and the current rung: none.
// 9. A subscription pays, the target is the unpriced rank-0 tier below the
// 9. The target ranks below both the held rung and the current rung: none.
// 10. A subscription pays, the target is the unpriced rank-0 tier below the
// current rung, and a prediction that does not put the target on the ladder
// exists or the cancellation is scheduled: none.
// 10. The target is the unpriced rank-0 tier and the organization holds nothing
// 11. The target is the unpriced rank-0 tier and the organization holds nothing
// on the ladder: none, since that tier is conferred.
// 11. The target ranks below the current rung and no subscription pays for it:
// 12. The target ranks below the current rung and no subscription pays for it:
// none, since a member cannot end a grant.
// 12. The organization holds nothing on the ladder, or the target ranks above a
// 13. The organization holds nothing on the ladder, or the target ranks above a
// rung no subscription pays for: Checkout, through the price rule.
// 13. The target is the unpriced rank-0 tier: Cancel.
// 14. Otherwise: Switch, through the price rule.
// 14. The target is the unpriced rank-0 tier: Cancel.
// 15. The position is suspended: none, with Suspended.
// 16. Otherwise: Switch, through the price rule.
//
// The paused rule sits above the held rung's rule 8 and the free rung's rule
// 14 because it replaces their Cancel answers on a paused position, which is
// offered no move at all. The suspended rule sits below them because they keep
// their Cancel answers on a suspended position: cancelling buys nothing and
// charges nothing, while a switch would invoice with the failed invoice still
// open.
//
// The price rule decides Checkout and Switch: a product outside the member
// gate is offered none; a tier with no price is offered the move disabled; a
@@ -197,6 +215,10 @@ func Offer(pos Position, t Target) Move {
if t.ProductID == pos.Current {
return Move{Reason: Current}
}
if pos.Paused {
m, _ := a.answer(Paused)
return m
}
if m, ok := a.heldRules(); ok {
return m
}
@@ -219,7 +241,7 @@ func (a ask) answer(reason Reason) (Move, bool) {
return Move{Reason: reason, Relation: a.rel, Held: a.isHeld()}, true
}
// heldRules applies rules 4 to 8.
// heldRules applies rules 5 to 9.
func (a ask) heldRules() (Move, bool) {
held := a.isHeld()
switch {
@@ -237,7 +259,7 @@ func (a ask) heldRules() (Move, bool) {
return Move{}, false
}
// offersNothing applies rules 9 to 11.
// offersNothing applies rules 10 to 12.
func (a ask) offersNothing() (Move, bool) {
switch {
case a.Subscribed && a.freeRung() && a.rel == Downgrade &&
@@ -251,7 +273,7 @@ func (a ask) offersNothing() (Move, bool) {
return Move{}, false
}
// route applies rules 12 to 14.
// route applies rules 13 to 16.
func (a ask) route() Move {
var m Move
switch {
@@ -259,6 +281,8 @@ func (a ask) route() Move {
m = priceRule(Checkout, a.Target)
case a.freeRung():
m = Move{Kind: Cancel, Enabled: true}
case a.Suspended:
m = Move{Reason: Suspended}
default:
m = priceRule(Switch, a.Target)
}
+64 -17
View File
@@ -64,16 +64,18 @@ func TestOffer(t *testing.T) {
return tg
}
tests := []struct {
name string
pos Position
target Target
kind Kind
enabled bool
reason Reason
relation Relation
held bool
}{
// suspended and paused change the position as the Products page reads it
// from a suspended row, whose subscription is paused for the second.
suspended := func(p Position) Position {
p.Suspended = true
return p
}
paused := func(p Position) Position {
p.Suspended, p.Paused = true, true
return p
}
tests := []offerCase{
// What pays for the current rung, against a target above, below and equal.
{name: "nothing held offers checkout of any tier", pos: holds("", false), target: target("basic"),
kind: Checkout, enabled: true, relation: Available},
@@ -174,6 +176,33 @@ func TestOffer(t *testing.T) {
target: with(target("standard"), func(t *Target) { t.Listed = false }),
kind: Cancel, enabled: true, relation: Downgrade, held: true},
// A suspended position: the plan is the current one and is kept or
// cancelled, never switched.
{name: "suspended, a tier above", pos: suspended(holds("plus", true)), target: target("pro"),
reason: Suspended, relation: Upgrade},
{name: "suspended, a priced tier below", pos: suspended(holds("plus", true)), target: target("basic"),
reason: Suspended, relation: Downgrade},
{name: "suspended, the free rung that follows", pos: suspended(below("free", false)), target: target("free"),
kind: Cancel, enabled: true, relation: Downgrade},
{name: "suspended, the current tier", pos: suspended(holds("plus", true)), target: target("plus"),
reason: Current},
{name: "suspended, the held rung below", pos: suspended(held(nil)), target: target("standard"),
kind: Cancel, enabled: true, relation: Downgrade, held: true},
{name: "not suspended, a tier above", pos: holds("plus", true), target: target("pro"),
kind: Switch, enabled: true, relation: Upgrade},
{name: "not suspended, a priced tier below", pos: holds("plus", true), target: target("basic"),
kind: Switch, enabled: true, relation: Downgrade},
// A paused position is offered no move at all.
{name: "paused, a tier above", pos: paused(holds("plus", true)), target: target("pro"),
reason: Paused, relation: Upgrade},
{name: "paused, the held rung below", pos: paused(held(nil)), target: target("standard"),
reason: Paused, relation: Downgrade, held: true},
{name: "paused, the free rung that follows", pos: paused(below("free", false)), target: target("free"),
reason: Paused, relation: Downgrade},
{name: "paused, the current tier", pos: paused(holds("plus", true)), target: target("plus"),
reason: Current},
// Placement.
{name: "a target that is not a tier of the ladder offers nothing", pos: holds("", false), target: target("elsewhere"),
reason: NotOnLadder},
@@ -181,13 +210,31 @@ func TestOffer(t *testing.T) {
reason: Unplaced},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := Offer(tc.pos, tc.target)
want := Move{Kind: tc.kind, Enabled: tc.enabled, Reason: tc.reason, Relation: tc.relation, Held: tc.held}
if got != want {
t.Errorf("Offer = %+v, want %+v", got, want)
}
})
t.Run(tc.name, func(t *testing.T) { checkOffer(t, tc) })
}
}
// offerCase is one row of TestOffer: a position, a target and the move the
// decision answers.
type offerCase struct {
name string
pos Position
target Target
kind Kind
enabled bool
reason Reason
relation Relation
held bool
}
// checkOffer compares the decision with the row's move, printing the row's
// name so that a failure says which rule's row broke.
func checkOffer(t *testing.T, tc offerCase) {
t.Helper()
got := Offer(tc.pos, tc.target)
want := Move{Kind: tc.kind, Enabled: tc.enabled, Reason: tc.reason, Relation: tc.relation, Held: tc.held}
if got != want {
t.Errorf("Offer(%s) = %+v, want %+v", tc.name, got, want)
}
}
+8 -2
View File
@@ -1486,9 +1486,15 @@ func (t TierViewModel) CurrentBadge() Badge {
// CancelBadge marks a ladder whose subscription cancels at period end; the
// member-facing chip carries the date itself (unlike the operator's
// Canceling badge, the date is the fact a member needs).
// Canceling badge, the date is the fact a member needs). It drops its
// tooltip on a suspended plan, whose entitlements are suspended, so access
// does not continue until the date.
func (l LadderViewModel) CancelBadge() Badge {
return Badge{Label: "Cancels on " + l.CancelsOn, Tone: "warning", Title: "The plan does not renew; access continues until this date."}
badge := Badge{Label: "Cancels on " + l.CancelsOn, Tone: "warning", Title: "The plan does not renew; access continues until this date."}
if currentTier(&l).Suspension != "" {
badge.Title = ""
}
return badge
}
func (d AddonsData) Header() SectionHeader {
@@ -32,7 +32,9 @@ import (
"slices"
"strings"
"testing"
"time"
"github.com/alexedwards/scs/v2"
stripe "github.com/stripe/stripe-go/v81"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
@@ -436,3 +438,91 @@ func TestCheckoutAnswersHTMXWithAClientRedirect(t *testing.T) {
}
})
}
// suspendedCheckoutRow is one target of TestCheckoutRefusesALadderWhosePlanIsSuspended.
type suspendedCheckoutRow struct {
name string
price func(suspendedCheckout) world.Price
refused bool
}
// suspendedCheckout is an organization whose subscription to Plus on the
// ladder Hold is suspended, with the tiers around it, a second ladder it
// holds nothing on, and a product that is a tier of both.
type suspendedCheckout struct {
w *world.World
h *BillingCheckoutHandler
sm *scs.SessionManager
mock *stripetest.MockBackend
org world.Org
basic, pro, solo, shared world.Price
}
// TestCheckoutRefusesALadderWhosePlanIsSuspended: a plan whose payment is due
// is the organization's plan on its ladder, so Checkout of any tier of that
// ladder, or of a product one of whose ladders it is, answers 422 with the
// plan sentence and no Stripe call, while a tier of another ladder passes
// (suspended-plans-stay-held D4).
func TestCheckoutRefusesALadderWhosePlanIsSuspended(t *testing.T) {
ctx := context.Background()
db := dbtest.Open(t)
w := world.New(t, db)
priced := func(name string, cents int32) (world.Product, world.Price) {
p := w.Product(name)
price := w.Price(p, cents)
w.MapPrice(price)
return p, price
}
basic, basicPrice := priced("Basic", 500)
plus, _ := priced("Plus", 1000)
pro, proPrice := priced("Pro", 3000)
solo, soloPrice := priced("Solo", 800)
shared, sharedPrice := priced("Shared", 700)
w.Ladder("Hold", basic, plus, pro, shared)
w.Ladder("Other", solo, shared)
org := w.Org("Suspended", w.OrgType("susp"), w.Person("Owner"))
sub := w.Subscribe(org, world.SubOpts{PeriodEnd: noon(2099, time.January, 15)}, plus)
w.Suspend(sub, "past_due")
if _, err := internalstripe.New(db).UpsertCustomerMapping(ctx, internalstripe.UpsertCustomerMappingParams{
BillingAccountID: sub.BillingAccountID, SyncStatus: "pending",
}); err != nil {
t.Fatalf("customer mapping: %v", err)
}
mock := &stripetest.MockBackend{Err: errors.New("stripetest: no call expected")}
t.Cleanup(stripetest.Install(mock))
sm := authtest.NewSessionManager()
env := suspendedCheckout{
w: w, sm: sm, mock: mock, org: org, basic: basicPrice, pro: proPrice, solo: soloPrice, shared: sharedPrice,
h: &BillingCheckoutHandler{
Database: db, BillingQ: billing.New(db), EntitlementsQ: entitlements.New(db),
AuthConfig: &auth.Config{SessionManager: sm},
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
BaseURL: "https://example.test", StripeMode: "test",
},
}
for _, row := range []suspendedCheckoutRow{
{"a tier above", func(e suspendedCheckout) world.Price { return e.pro }, true},
{"a tier below", func(e suspendedCheckout) world.Price { return e.basic }, true},
{"a tier of a second ladder the organization holds nothing on", func(e suspendedCheckout) world.Price { return e.solo }, false},
{"a product that is a tier of both ladders", func(e suspendedCheckout) world.Price { return e.shared }, true},
} {
t.Run(row.name, func(t *testing.T) { checkSuspendedCheckout(t, env, row) })
}
}
func checkSuspendedCheckout(t *testing.T, env suspendedCheckout, row suspendedCheckoutRow) {
before := len(env.mock.Calls)
sctx := authtest.SignIn(t, env.sm, auth.SignIn{OrgID: env.org.ID})
rec := authtest.Post(t, sctx, env.h.HandleCheckout, "/billing/checkout", url.Values{"price_id": {row.price(env).ID}})
body := strings.TrimSpace(rec.Body.String())
if after := len(env.mock.Calls); after != before {
t.Errorf("Checkout called Stripe: %v", env.mock.Calls[before:])
}
switch {
case row.refused && (rec.Code != http.StatusUnprocessableEntity || body != checkoutPlanRefusal):
t.Errorf("Checkout of a tier on a suspended plan's ladder was not refused: status %d body %q, want 422 %q", rec.Code, body, checkoutPlanRefusal)
case !row.refused && (rec.Code != http.StatusServiceUnavailable || !strings.Contains(body, "billing setup in progress")):
t.Errorf("Checkout of a tier of another ladder: status %d body %q, want 503 billing setup in progress", rec.Code, body)
}
}
@@ -696,8 +696,8 @@ func TestMemberSwitch_RefusesTheHeldRung(t *testing.T) {
t.Errorf("the subscription's item changed")
}
// The refusal is the offer's, and a rung between the held rung and the
// current rung passes it. The held rung is answered Cancel by rule 7 and
// the rung below both by rule 8, whether or not either is mapped; only
// current rung passes it. The held rung is answered Cancel by rule 8 and
// the rung below both by rule 9, whether or not either is mapped; only
// Standard reaches the price rule, so it needs its mapping.
offer := func(target string) error {
price, err := billing.New(w.db).GetPrice(w.ctx, w.prices[target])
@@ -704,6 +704,38 @@ func TestCancelPredictionBody(t *testing.T) {
}
}
// TestCancelPredictionBodyPaymentDue pins the cancel confirmation of a plan
// whose payment is due: it names the end, in the forms of the design's copy
// rows 19 to 22 and 24.
func TestCancelPredictionBodyPaymentDue(t *testing.T) {
const date = "Jan 15, 2099"
cases := []struct {
name string
current string
rows []predRow
want string
}{
{"nothing follows", "Pro", []predRow{{"Wiki plans", "Pro", ""}}, "Pro ends on Jan 15, 2099."},
{"nothing follows on several ladders", "Pro", []predRow{{"Wiki plans", "Pro", ""}, {"Forum plans", "Pro", ""}}, "Pro ends on Jan 15, 2099."},
{"only the plan's own ladder is covered", "Pro", []predRow{{"Wiki plans", "Pro", "Plus"}}, "Pro ends on Jan 15, 2099, then you move to Plus."},
{"several ladders", "Pro", []predRow{{"Wiki plans", "Pro", "Plus"}, {"Forum plans", "Pro", ""}},
"Pro ends on Jan 15, 2099, then you move to Plus on Wiki plans and leave Forum plans."},
{"a keep beside a leave", "Plus", []predRow{{"Wiki plans", "Plus", "Plus"}, {"Forum plans", "Forum Pro", ""}},
"Plus ends on Jan 15, 2099, then you keep Plus on Wiki plans and leave Forum plans."},
{"every covered ladder keeps its plan", "Pro", []predRow{{"Wiki plans", "Pro", "Pro"}},
"Billing for Pro stops on Jan 15, 2099."},
}
for _, c := range cases {
p := predictionOf(c.rows...)
if got := p.bodyPaymentDue(c.current, date); got != c.want {
t.Errorf("%s:\n got %q\nwant %q", c.name, got, c.want)
}
}
if got := (&cancelPrediction{}).bodyPaymentDue("Pro", date); got != "" {
t.Errorf("an empty prediction reads %q, want nothing", got)
}
}
// predRow is one covered ladder of a prediction: its display name, the
// subscription's tier on it ("" when it holds none) and the tier that
// follows ("" when nothing does).
+14 -4
View File
@@ -81,10 +81,12 @@ func (h *MemberProductsHandler) GetSwitchPreview(w http.ResponseWriter, r *http.
h.renderPlansAfterMove(w, r.Context(), session.OrgID, err, "switch-preview")
return
}
// The switch changes the subscription that backs the org's rung on this
// ladder and refuses when there is none, so the preview refuses first
// with the same answer rather than quote a charge nothing will make.
if held, err := fulfillment.HasActiveSubscriptionOnLadder(r.Context(), h.Database, session.OrgID, ladderID); err != nil || !held {
// The switch changes the subscription that backs the org's position on
// this ladder, active or suspended, and refuses when there is none, so the
// preview refuses first with the same answer rather than quote a charge
// nothing will make. The offer decision above has already refused a
// suspended position.
if held, err := fulfillment.SubscriptionBacksLadder(r.Context(), h.Database, session.OrgID, ladderID); err != nil || !held {
if err == nil {
err = fulfillment.ErrNoActivePaidSubscription
}
@@ -333,6 +335,14 @@ func (h *MemberProductsHandler) renderPlansAfterMove(w http.ResponseWriter, ctx
data.Error = "This change isn't available under your current commitment terms."
case errors.Is(moveErr, fulfillment.ErrNoActivePaidSubscription):
data.Error = "There's no active subscription to change on this plan."
case errors.Is(moveErr, fulfillment.ErrSubscriptionSuspended):
data.Error = "Pay the unpaid invoice before changing this plan."
case errors.Is(moveErr, fulfillment.ErrSubscriptionPaused):
data.Error = "A paused plan cannot be changed."
case errors.Is(moveErr, fulfillment.ErrScheduledMoveApplied):
data.Error = "That plan change has already taken effect."
case errors.Is(moveErr, fulfillment.ErrImmediateCancelSuspended):
data.Error = "A plan with an unpaid invoice cannot end immediately."
case errors.Is(moveErr, fulfillment.ErrSameTier):
data.Error = "You're already on that plan."
case errors.Is(moveErr, fulfillment.ErrPriceInactive), errors.Is(moveErr, errPlanNotOpenToMembers),
+85 -16
View File
@@ -169,11 +169,17 @@ type TierViewModel struct {
// EndsOn is the date a scheduled cancellation takes effect on the
// current tier: the price line reads "ends" in place of "renews".
EndsOn string
// Suspension is the state of the current tier's plan when its position
// is suspended: "payment_due", or "paused" when its subscription is
// paused. Empty otherwise. The current card keeps the plan, replaces
// "renews" with the state on its price line, and adds the fact line; a
// paused card draws no control.
Suspension string
// CanCancel offers "Cancel plan" on the current tier of a
// subscription-backed plan with nothing scheduled or a downgrade
// scheduled, and CanKeep offers "Keep plan" while a downgrade or a
// cancellation is scheduled (plan-switch-timing D3, D9;
// grant-resumption D10).
// grant-resumption D10). A paused plan has neither.
CanCancel bool
CanKeep bool
// CancelBody is the cancel confirmation's text, on the current tier's
@@ -429,6 +435,7 @@ func (h *MemberProductsHandler) buildTier(ctx context.Context, b *plansBuild, la
// line and the next step on its own card.
func (h *MemberProductsHandler) setCurrent(ctx context.Context, tier *TierViewModel, ladderID string, pos plans.Position, facts ladderFacts) {
tier.Relation = "current"
tier.Suspension = facts.Suspension
tier.BillingPriceText, tier.BillingRenewsOn, tier.BillingPending =
h.resolveCurrentPlanBilling(ctx, facts.Position)
// A subscription-backed plan offers its next step on its own
@@ -450,6 +457,9 @@ func (h *MemberProductsHandler) setCurrent(ctx context.Context, tier *TierViewMo
default:
tier.CanCancel = true
}
if tier.Suspension == suspensionPaused {
tier.CanKeep, tier.CanCancel = false, false
}
}
// moveDisabledReason is what a move the decision offers disabled reads.
@@ -517,15 +527,31 @@ func combineCheckouts(ladders []LadderViewModel, moves map[string][]plans.Move)
// setCancelBodies names, on the current tier's control, the held rung's and
// the free rung's, the date access ends and what follows on each ladder the
// cancellation changes, each opening with this ladder's current tier
// (plan-switch-timing D7, D9; grant-resumption D16).
// (plan-switch-timing D7, D9; grant-resumption D16). On a ladder whose
// current tier reads payment_due the body names the end instead of the access
// that continues, since the entitlements are suspended, and the sentence that
// the unpaid invoice stays due follows it; a paused ladder gets no body, since
// it offers no cancellation.
func setCancelBodies(lvm *LadderViewModel, prediction *cancelPrediction) {
current := currentTier(lvm)
if current.Suspension == suspensionPaused {
return
}
body := ""
for _, t := range lvm.Tiers {
if t.Relation == "current" && t.BillingRenewsOn != "" && prediction != nil {
body = prediction.body(t.Name, t.BillingRenewsOn)
if prediction != nil && current.BillingRenewsOn != "" {
if current.Suspension == suspensionPaymentDue {
body = prediction.bodyPaymentDue(current.Name, current.BillingRenewsOn)
} else {
body = prediction.body(current.Name, current.BillingRenewsOn)
}
}
if body == "" {
switch {
case current.Suspension == suspensionPaymentDue:
if body == "" {
body = current.Name + " ends with this billing period."
}
body += " The unpaid invoice stays due."
case body == "":
body = "You keep your current plan until the end of this billing period."
}
for i := range lvm.Tiers {
@@ -535,6 +561,17 @@ func setCancelBodies(lvm *LadderViewModel, prediction *cancelPrediction) {
}
}
// currentTier is the ladder's current tier, the zero tier when the ladder
// draws none.
func currentTier(lvm *LadderViewModel) TierViewModel {
for _, t := range lvm.Tiers {
if t.Relation == "current" {
return t
}
}
return TierViewModel{}
}
// plansOfferPaidMove reports whether any tier's move is enabled and costs
// money: a checkout, or a switch, whose target is always a paid rung and
// which charges the card on file now or at renewal. A cancel costs
@@ -608,7 +645,47 @@ func (p *cancelPrediction) body(current, date string) string {
if p == nil || len(p.rows) == 0 {
return ""
}
var keeps, moves, leaves []*predictedClause
keeps, moves, leaves := p.clauses()
switch {
case len(moves) == 0 && len(leaves) == 0:
return "You keep " + current + "; billing stops on " + date + "."
case len(moves) == 0 && len(keeps) == 0:
return "You keep " + current + " until " + date + "; then the plan ends."
case len(p.rows) == 1:
return "You keep " + current + " until " + date + ", then move to " + moves[0].tier + "."
}
return "You keep " + current + " until " + date + ", then " + joinWithAnd(clauseWords(keeps, moves, leaves)) + "."
}
// bodyPaymentDue renders the cancel confirmation for a card whose plan's
// payment is due, in the forms body chooses between but naming the end:
// access does not continue, since the entitlements are suspended. "Billing
// for X stops on D." when every covered ladder keeps its tier, "X ends on D."
// when nothing follows anywhere, "X ends on D, then you move to Y." when only
// the card's own ladder is covered and another tier follows, and otherwise
// "X ends on D, then you " followed by the clauses of body. Empty when the
// prediction covers no ladder.
func (p *cancelPrediction) bodyPaymentDue(current, date string) string {
if p == nil || len(p.rows) == 0 {
return ""
}
keeps, moves, leaves := p.clauses()
ends := current + " ends on " + date
switch {
case len(moves) == 0 && len(leaves) == 0:
return "Billing for " + current + " stops on " + date + "."
case len(moves) == 0 && len(keeps) == 0:
return ends + "."
case len(p.rows) == 1:
return ends + ", then you move to " + moves[0].tier + "."
}
return ends + ", then you " + joinWithAnd(clauseWords(keeps, moves, leaves)) + "."
}
// clauses groups the prediction's rows into the keep, move and leave clauses
// of the cancel body. Clauses of one kind naming the same tier merge at their
// first ladder.
func (p *cancelPrediction) clauses() (keeps, moves, leaves []*predictedClause) {
merge := func(list []*predictedClause, kind, tier, ladder string) []*predictedClause {
for _, c := range list {
if c.tier == tier {
@@ -628,15 +705,7 @@ func (p *cancelPrediction) body(current, date string) string {
moves = merge(moves, "move", r.FollowingProductName.String, r.LadderName)
}
}
switch {
case len(moves) == 0 && len(leaves) == 0:
return "You keep " + current + "; billing stops on " + date + "."
case len(moves) == 0 && len(keeps) == 0:
return "You keep " + current + " until " + date + "; then the plan ends."
case len(p.rows) == 1:
return "You keep " + current + " until " + date + ", then move to " + moves[0].tier + "."
}
return "You keep " + current + " until " + date + ", then " + joinWithAnd(clauseWords(keeps, moves, leaves)) + "."
return keeps, moves, leaves
}
// clauseWords renders D16's clauses in its order, keeps then moves then
+37 -5
View File
@@ -4,7 +4,7 @@
package server
// The Products page's plans region, characterized state by state: for each of
// fifteen organizations, the view model buildPlansData returns, cut to the
// twenty-two organizations, the view model buildPlansData returns, cut to the
// fixture's ladders and without the billing paragraph and the preview, and the
// member_plans.html render of that data, compared byte for byte with the files
// under testdata/member_plans. A change to anything else a member sees on the
@@ -93,7 +93,7 @@ func registerGoldenKeys(t testing.TB, w *world.World) {
}
}
// plansGoldenFixture is the three ladders and fifteen organizations the
// plansGoldenFixture is the three ladders and twenty-two organizations the
// plans golden reads, built once. Every date a state prints is fixed, far
// enough ahead that the clock never reaches it, and falls at 12:00 UTC.
type plansGoldenFixture struct {
@@ -137,7 +137,7 @@ func newPlansGoldenFixture(ctx context.Context, t *testing.T, w *world.World) *p
free, _ := product("Free", 0, false)
basic, _ := product("Basic", 500, true)
plus, _ := product("Plus", 1000, true)
plus, plusPrice := product("Plus", 1000, true)
standard, standardPrice := product("Standard", 2000, true)
pro, _ := product("Pro", 3000, true)
registerGoldenKeys(t, w)
@@ -172,13 +172,15 @@ func newPlansGoldenFixture(ctx context.Context, t *testing.T, w *world.World) *p
orgType := w.OrgType("plans")
w.DefaultLadder(orgType, wiki)
noDefaultType := w.OrgType("nodef")
owner := w.Person("Owner")
periodEnd := noon(2099, time.January, 15)
org := func(state string) world.Org {
o := w.Org(state, orgType, owner)
orgOf := func(state string, typ string) world.Org {
o := w.Org(state, typ, owner)
f.orgs = append(f.orgs, plansGoldenOrg{state: state, id: o.ID})
return o
}
org := func(state string) world.Org { return orgOf(state, orgType) }
org("nothing-held")
@@ -197,6 +199,35 @@ func newPlansGoldenFixture(ctx context.Context, t *testing.T, w *world.World) *p
sub = w.Subscribe(o, world.SubOpts{PeriodEnd: periodEnd}, pro)
w.Suspend(sub, "past_due")
o = org("paid-pro-unpaid")
sub = w.Subscribe(o, world.SubOpts{PeriodEnd: periodEnd}, pro)
w.Suspend(sub, "unpaid")
o = org("paid-pro-paused")
sub = w.Subscribe(o, world.SubOpts{PeriodEnd: periodEnd}, pro)
w.Suspend(sub, "paused")
o = orgOf("paid-pro-past-due-no-default", noDefaultType)
sub = w.Subscribe(o, world.SubOpts{PeriodEnd: periodEnd}, pro)
w.Suspend(sub, "past_due")
o = org("paid-pro-past-due-cancellation-scheduled")
sub = w.Subscribe(o, world.SubOpts{PeriodEnd: periodEnd}, pro)
w.CancelAtPeriodEnd(sub)
w.ScheduleCancellation(sub, noon(2099, time.February, 10))
w.Suspend(sub, "past_due")
o = org("paid-pro-past-due-downgrade-scheduled")
sub = w.Subscribe(o, world.SubOpts{PeriodEnd: periodEnd}, pro)
w.ScheduleDowngrade(sub, standardPrice, periodEnd)
w.Suspend(sub, "past_due")
// Stripe applied this downgrade at the renewal whose invoice is unpaid.
o = org("paid-pro-past-due-downgrade-landed")
sub = w.Subscribe(o, world.SubOpts{PeriodEnd: periodEnd}, pro)
w.ScheduleDowngrade(sub, plusPrice, noon(2020, time.January, 15))
w.Suspend(sub, "past_due")
o = org("paid-pro-cancellation-flag-and-row")
w.Issue(o, plus, world.IssueOpts{Marked: true})
sub = w.Subscribe(o, world.SubOpts{PeriodEnd: periodEnd}, pro)
@@ -219,6 +250,7 @@ func newPlansGoldenFixture(ctx context.Context, t *testing.T, w *world.World) *p
w.Subscribe(o, world.SubOpts{NullPeriodEnd: true}, plus)
w.ConferUnprojected(org("unprojected-subscription"), plus)
w.ConferUnprojectedSuspended(org("unprojected-subscription-past-due"), plus)
o = org("paid-pro-block-commitment")
w.Issue(o, plus, world.IssueOpts{Marked: true, ValidUntil: noon(2099, time.February, 20)})
@@ -18,13 +18,19 @@ import (
"context"
"database/sql"
"errors"
"io"
"log/slog"
"strings"
"testing"
"time"
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world"
)
// mapPrice gives the product's price a synced Stripe mapping with no
@@ -359,3 +365,87 @@ func TestAddonsListOnlyWhatTheDecisionOffers(t *testing.T) {
}
})
}
// TestPlansHoldASuspendedPlan: the plan whose subscription the reconcile
// suspended stays the organization's current plan on the Products page
// (suspended-plans-stay-held D1, D3, D7, D9). Its payment due, it offers
// Cancel plan and no Subscribe, Upgrade or switch on its ladder; paused, it
// offers no control at all.
func TestPlansHoldASuspendedPlan(t *testing.T) {
ctx := context.Background()
db := dbtest.Open(t)
w := world.New(t, db)
var products []world.Product
for _, p := range []struct {
name string
cents int32
}{{"Basic", 500}, {"Plus", 1000}, {"Pro", 3000}} {
product := w.Product(p.name)
w.MapPrice(w.Price(product, p.cents))
products = append(products, product)
}
w.Ladder("Hold", products...)
orgType := w.OrgType("hold")
owner := w.Person("Owner")
h, err := NewMemberProductsHandler(MemberProductsConfig{
BillingQ: billing.New(db), EntitlementsQ: entitlements.New(db),
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Database: db, StripeMode: "test",
})
if err != nil {
t.Fatalf("member handler: %v", err)
}
suspendedPlan := func(status string) map[string]TierViewModel {
o := w.Org("hold-"+status, orgType, owner)
if status == "paused" {
// A held rung gives the tier below a Cancel without the paused rule.
w.Issue(o, products[0], world.IssueOpts{Marked: true})
}
sub := w.Subscribe(o, world.SubOpts{PeriodEnd: noon(2099, time.January, 15)}, products[2])
w.Suspend(sub, status)
return tiersOf(ctx, h, w, o.ID, "Hold")
}
t.Run("payment due", func(t *testing.T) {
tiers := suspendedPlan("past_due")
if cur := tiers["Pro"]; cur.Relation != "current" || cur.Suspension != "payment_due" || !cur.CanCancel {
t.Errorf("Pro: relation=%q suspension=%q can cancel=%v, want the current plan, payment due, with Cancel plan",
cur.Relation, cur.Suspension, cur.CanCancel)
}
for name, tier := range tiers {
if tier.MoveKind == "switch" {
t.Errorf("tier %s offers a switch on a suspended plan", name)
}
if tier.MoveKind == "checkout" {
t.Errorf("tier %s offers Checkout on a suspended plan", name)
}
}
})
t.Run("paused", func(t *testing.T) {
tiers := suspendedPlan("paused")
if cur := tiers["Pro"]; cur.Relation != "current" || cur.Suspension != "paused" || cur.CanKeep || cur.CanCancel {
t.Errorf("Pro: relation=%q suspension=%q can keep=%v can cancel=%v, want the current plan, paused, with no control",
cur.Relation, cur.Suspension, cur.CanKeep, cur.CanCancel)
}
for name, tier := range tiers {
if tier.MoveKind != "" {
t.Errorf("tier %s offers a move on a paused plan", name)
}
}
})
}
// tiersOf builds the Products page for orgID and returns the tiers of the
// named ladder by name, without the World's suffix.
func tiersOf(ctx context.Context, h *MemberProductsHandler, w *world.World, orgID, ladder string) map[string]TierViewModel {
tiers := map[string]TierViewModel{}
for _, l := range h.buildPlansData(ctx, orgID).Ladders {
if l.Name != ladder+" "+w.Sfx {
continue
}
for _, tier := range l.Tiers {
tiers[strings.TrimSuffix(tier.Name, " "+w.Sfx)] = tier
}
}
return tiers
}
+239 -7
View File
@@ -26,18 +26,23 @@ import (
"context"
"database/sql"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"github.com/google/uuid"
stripe "github.com/stripe/stripe-go/v81"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/fulfillment"
internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
"git.coopcloud.tech/wiki-cafe/member-console/internal/stripetest"
)
const (
@@ -239,13 +244,7 @@ func TestSwitchRefusesWhatTheOfferDoesNotOffer(t *testing.T) {
return m.ladderID, price, m.prices[2]
}},
{"the tier a scheduled downgrade moves to", func(t *testing.T, m offerMember) (string, string, string) {
if _, err := m.db.Exec(`INSERT INTO core.subscription_scheduled_changes
(subscription_id, change_type, target_price_id, effective_at, effective_trigger)
SELECT s.subscription_id, 'plan_switch', $2, now() + interval '10 days', 'period_end'
FROM core.subscriptions s JOIN core.accounts a ON a.billing_account_id = s.billing_account_id
WHERE a.org_id = $1`, m.orgID, m.prices[0]); err != nil {
t.Fatalf("schedule the downgrade: %v", err)
}
m.scheduleDowngrade(t, m.prices[0], "now() + interval '10 days'")
return m.ladderID, m.prices[0], m.prices[2]
}},
{"a product that is not a tier of the named ladder", func(t *testing.T, m offerMember) (string, string, string) {
@@ -374,3 +373,236 @@ func TestSwitchRefusesWhenAReadFails(t *testing.T) {
})
}
}
const (
offerPayFirst = "Pay the unpaid invoice before changing this plan."
offerPaused = "A paused plan cannot be changed."
offerLanded = "That plan change has already taken effect."
offerNoImmediate = "A plan with an unpaid invoice cannot end immediately."
offerRefusalLogMsg = "switch refused: the plan is suspended"
)
// holdAs makes the mock hold the member's subscription as status, on the tier
// of the given rank, and reconciles it: the subscription row reads status and
// its provisions read suspended. mod changes the mock's subscription first.
func (m offerMember) holdAs(t *testing.T, status stripe.SubscriptionStatus, rank int, mod func(*stripe.Subscription)) {
t.Helper()
sub := m.sub(rank)
sub.Status = status
if mod != nil {
mod(sub)
}
m.mock.Sub = sub
if err := fulfillment.ReconcileSubscription(context.Background(), m.db, discardLogger(), stripetest.KeyMode(), m.stripeSubID, "test:suspend"); err != nil {
t.Fatalf("reconcile as %s: %v", status, err)
}
}
// suspendedSwitchRow is one request made on a payment-due plan.
type suspendedSwitchRow struct {
name string
preview bool
price func(offerMember) string
}
// checkSuspendedSwitch makes the row's request on a payment-due plan and
// expects the pay-first refusal.
func checkSuspendedSwitch(t *testing.T, row suspendedSwitchRow) {
m := seedOfferMember(t)
m.holdAs(t, stripe.SubscriptionStatusPastDue, 1, nil)
refused(t, m.ask(t, m.h, row.preview, m.ladderID, row.price(m)), offerPayFirst)
}
// TestSwitchRefusesASuspendedPlan: a switch and its preview on a plan whose
// payment is due answer "Pay the unpaid invoice before changing this plan."
// whatever the target, and on a paused plan "A paused plan cannot be changed.",
// with no Stripe call (suspended-plans-stay-held D5).
func TestSwitchRefusesASuspendedPlan(t *testing.T) {
for _, row := range []suspendedSwitchRow{
{"a switch up", false, func(m offerMember) string { return m.prices[2] }},
{"a switch down", false, func(m offerMember) string { return m.prices[0] }},
{"a switch to the current tier", false, func(m offerMember) string { return m.prices[1] }},
{"the preview of the switch up", true, func(m offerMember) string { return m.prices[2] }},
} {
t.Run(row.name, func(t *testing.T) { checkSuspendedSwitch(t, row) })
}
t.Run("a switch up on a paused plan", func(t *testing.T) {
m := seedOfferMember(t)
m.holdAs(t, stripe.SubscriptionStatusPaused, 1, nil)
refused(t, m.ask(t, m.h, false, m.ladderID, m.prices[2]), offerPaused)
})
t.Run("the refusal is logged with the ladder and the subscription's status", func(t *testing.T) {
m := seedOfferMember(t)
m.holdAs(t, stripe.SubscriptionStatusPastDue, 1, nil)
logs := &capturedLog{}
h := *m.h
h.Logger = slog.New(logs)
m.ask(t, &h, false, m.ladderID, m.prices[2])
checkRefusalLog(t, logs, m.ladderID)
})
}
// checkRefusalLog fails unless the captured records hold the refusal at Info
// with the ladder and the subscription's status.
func checkRefusalLog(t *testing.T, logs *capturedLog, ladderID string) {
t.Helper()
var found *capturedRecord
for _, r := range logs.records() {
if r.message == offerRefusalLogMsg {
found = &r
}
}
if found == nil {
t.Fatalf("no log record %q", offerRefusalLogMsg)
}
if found.level != slog.LevelInfo {
t.Errorf("the refusal's log record is at %s, want INFO", found.level)
}
if got := found.attrs["ladder_id"]; got != ladderID {
t.Errorf("the refusal's log record has ladder_id %q, want %q", got, ladderID)
}
status, ok := found.attrs["subscription_status"]
if !ok {
t.Fatal("the refusal's log record has no subscription_status")
}
if status != "past_due" {
t.Errorf("the refusal's log record has subscription_status %q, want \"past_due\"", status)
}
}
// capturedRecord is one record of a captured logger.
type capturedRecord struct {
level slog.Level
message string
attrs map[string]string
}
// capturedLog is a slog handler that keeps every record, so a test compares
// the level, the message and the named attributes and never formatted text.
type capturedLog struct {
mu sync.Mutex
list []capturedRecord
}
func (*capturedLog) Enabled(context.Context, slog.Level) bool { return true }
func (l *capturedLog) WithAttrs([]slog.Attr) slog.Handler { return l }
func (l *capturedLog) WithGroup(string) slog.Handler { return l }
func (l *capturedLog) Handle(_ context.Context, r slog.Record) error {
rec := capturedRecord{level: r.Level, message: r.Message, attrs: map[string]string{}}
r.Attrs(func(a slog.Attr) bool {
rec.attrs[a.Key] = a.Value.String()
return true
})
l.mu.Lock()
defer l.mu.Unlock()
l.list = append(l.list, rec)
return nil
}
func (l *capturedLog) records() []capturedRecord {
l.mu.Lock()
defer l.mu.Unlock()
return append([]capturedRecord(nil), l.list...)
}
// planActRow is one plan act posted to the Products page.
type planActRow struct {
name string
sentence string
// setup holds the member's subscription as the row needs it.
setup func(t *testing.T, m offerMember)
// post sends the act through h.
post func(h *server.MemberProductsHandler, rec *httptest.ResponseRecorder, req *http.Request)
form func(m offerMember) url.Values
}
// writesSince lists the requests the mock received after mark that are not reads.
func writesSince(m offerMember, mark int) []string {
var writes []string
for _, c := range m.mock.Calls[mark:] {
if c.Method != http.MethodGet {
writes = append(writes, stripetest.Line(c))
}
}
return writes
}
// TestPlanActsRefuseWhatFulfillmentRefuses: Keep plan and Cancel plan render
// the sentence of what fulfillment refuses on a suspended plan, in the plans
// partial's error slot, and write nothing to Stripe (suspended-plans-stay-held
// D6).
func TestPlanActsRefuseWhatFulfillmentRefuses(t *testing.T) {
keep := func(h *server.MemberProductsHandler, rec *httptest.ResponseRecorder, req *http.Request) {
h.PostKeep(rec, req)
}
cancel := func(h *server.MemberProductsHandler, rec *httptest.ResponseRecorder, req *http.Request) {
h.PostCancel(rec, req)
}
ladder := func(m offerMember) url.Values { return url.Values{"ladder_id": {m.ladderID}} }
immediate := func(m offerMember) url.Values {
return url.Values{"ladder_id": {m.ladderID}, "timing": {"immediate"}}
}
paused := func(t *testing.T, m offerMember) { m.holdAs(t, stripe.SubscriptionStatusPaused, 1, nil) }
paymentDue := func(t *testing.T, m offerMember) { m.holdAs(t, stripe.SubscriptionStatusPastDue, 1, nil) }
landed := func(t *testing.T, m offerMember) {
m.scheduleDowngrade(t, m.prices[0], "now() - interval '1 day'")
// Stripe applied the downgrade at the renewal whose invoice is unpaid:
// the subscription carries the lower tier's price, and the schedule
// the console attached is still on it.
m.holdAs(t, stripe.SubscriptionStatusPastDue, 0, func(s *stripe.Subscription) {
s.Schedule = &stripe.SubscriptionSchedule{ID: "sub_sched_landed", Metadata: map[string]string{"scheduled_from_price_id": m.prices[1]}}
})
}
for _, row := range []planActRow{
{"Keep plan on a paused plan", offerPaused, paused, keep, ladder},
{"Cancel plan on a paused plan", offerPaused, paused, cancel, ladder},
{"Keep plan on a payment-due plan whose downgrade has landed", offerLanded, landed, keep, ladder},
{"a posted immediate cancellation on a payment-due plan", offerNoImmediate, paymentDue, cancel, immediate},
} {
t.Run(row.name, func(t *testing.T) { checkPlanAct(t, row) })
}
}
func checkPlanAct(t *testing.T, row planActRow) {
m := seedOfferMember(t)
row.setup(t, m)
mark := len(m.mock.Calls)
req := httptest.NewRequest(http.MethodPost, "/partials/member/plans/act", strings.NewReader(row.form(m).Encode())).WithContext(m.sctx)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
row.post(m.h, rec, req)
body := rec.Body.String()
if !strings.Contains(body, row.sentence) {
t.Errorf("want %q in:\n%s", row.sentence, body)
}
if row.sentence == offerLanded {
// A refusal renders only the error; the page the member reloads
// must not offer the Keep plan the endpoint refused.
page := httptest.NewRecorder()
m.h.GetPlans(page, httptest.NewRequest(http.MethodGet, "/partials/member/plans", nil).WithContext(m.sctx))
if strings.Contains(page.Body.String(), ">Keep plan</button>") {
t.Errorf("the refused Keep re-renders a Keep plan for the landed downgrade")
}
}
if writes := writesSince(m, mark); len(writes) != 0 {
t.Errorf("a refused act wrote to Stripe: %v", writes)
}
}
// scheduleDowngrade records the downgrade of the member's subscription to
// price at the period end, as the switch does. at is the SQL expression of
// the row's effective_at.
func (m offerMember) scheduleDowngrade(t *testing.T, priceID, at string) {
t.Helper()
if _, err := m.db.Exec(`INSERT INTO core.subscription_scheduled_changes
(subscription_id, change_type, target_price_id, effective_at, effective_trigger)
SELECT s.subscription_id, 'plan_switch', $2, `+at+`, 'period_end'
FROM core.subscriptions s JOIN core.accounts a ON a.billing_account_id = s.billing_account_id
WHERE a.org_id = $1`, m.orgID, priceID); err != nil {
t.Fatalf("schedule the downgrade: %v", err)
}
}
+60 -9
View File
@@ -50,7 +50,8 @@ func newPositionLoader(billingQ billing.Querier, entsQ entitlements.Querier, db
}
// positions reads the organization's position on each plan ladder it holds,
// keyed by ladder: the active attachments of its default pool, with the
// keyed by ladder: the live attachments of its default pool, active or
// suspended (a suspended one is the current plan and delivers nothing), with the
// provision, subscription and scheduled changes behind each. The same product
// can be current on one ladder and a move target on another, so the rows are
// keyed by ladder. An attachment marks the rung whether a grant, a
@@ -68,7 +69,7 @@ func (l *positionLoader) positions(ctx context.Context, orgID string) (map[strin
if err != nil {
return nil, fmt.Errorf("read the positions in pool %s: %w", pool.PoolID, err)
}
return entitlements.PositionsByLadder(entitlements.ExcludeSuspended(rows)), nil
return entitlements.PositionsByLadder(rows), nil
}
// ladderFacts are what the Products page draws beside a position: the row
@@ -80,6 +81,32 @@ type ladderFacts struct {
ScheduledName string
ScheduledOn string
Prediction *cancelPrediction
// Suspension is "" for a position that is not suspended, "paused" when
// its subscription is paused and "payment_due" for every other status of
// a suspended position.
Suspension string
}
// The words a suspended position's state is shown in.
const (
suspensionPaymentDue = "payment_due"
suspensionPaused = "paused"
)
// suspensionOf names the state of a position from the ladder row's status,
// which decides whether the position is suspended, and the subscription's
// status, which chooses the words: "" for a position that is not suspended,
// "paused" for a paused subscription and "payment_due" for every other
// status, including a subscription row not projected yet.
func suspensionOf(positionStatus, subscriptionStatus string) string {
switch {
case positionStatus != "suspended":
return ""
case subscriptionStatus == "paused":
return suspensionPaused
default:
return suspensionPaymentDue
}
}
// onLadder reads the organization's position on one ladder, given the row of
@@ -98,12 +125,20 @@ type ladderFacts struct {
// whose row has not been projected yet: pool_provisions.subscription_id
// carries no foreign key, so the provision can name it before then, and the
// position is subscribed with no period end.
//
// A suspended position is the current one, and pos.Suspended says so; it is
// paused only when its subscription's status is paused. A suspended
// position's scheduled downgrade whose date is not after now has already been
// applied by Stripe, so it is left unset.
func (l *positionLoader) onLadder(ctx context.Context, ladderID string, tiers []billing.ListTiersByLadderWithProductsRow, held entitlements.ListLadderPositionsByPoolRow) (plans.Position, ladderFacts, error) {
pos := plans.Position{Current: held.ProductID}
for _, t := range tiers {
pos.Tiers = append(pos.Tiers, plans.Tier{ProductID: t.ProductID, Rank: t.Rank})
}
facts := ladderFacts{Position: held}
facts.Suspension = suspensionOf(held.PositionStatus, held.SubscriptionStatus.String)
pos.Suspended = held.PositionStatus == "suspended"
pos.Paused = facts.Suspension == suspensionPaused
if held.ProductID == "" || !held.SubscriptionID.Valid {
return pos, facts, nil
}
@@ -111,7 +146,7 @@ func (l *positionLoader) onLadder(ctx context.Context, ladderID string, tiers []
if !held.ProjectedSubscriptionID.Valid {
return pos, facts, nil
}
scheduledChanges(held, &pos, &facts)
scheduledChanges(held, &pos, &facts, l.now())
if at, ok := cancellationTakesEffect(held.CurrentPeriodEnd, held.CommitmentEnd, held.EarlyTerminationPolicy, l.now()); ok {
if err := l.heldAndPrediction(ctx, ladderID, held, at, &pos, &facts); err != nil {
return pos, facts, err
@@ -124,8 +159,9 @@ func (l *positionLoader) onLadder(ctx context.Context, ladderID string, tiers []
// which the subscription records itself or a commitment defers to its
// boundary as a scheduled change, and, when none is armed, the downgrade
// scheduled for the period end (plan-switch-timing D7). The position row
// carries both scheduled changes.
func scheduledChanges(held entitlements.ListLadderPositionsByPoolRow, pos *plans.Position, facts *ladderFacts) {
// carries both scheduled changes. A suspended position's downgrade whose date
// is not after now is left out, because Stripe has already applied it.
func scheduledChanges(held entitlements.ListLadderPositionsByPoolRow, pos *plans.Position, facts *ladderFacts, now time.Time) {
if held.CancelAtPeriodEnd.Bool && held.CurrentPeriodEnd.Valid {
facts.CancelsOn = held.CurrentPeriodEnd.Time.Format("Jan 2, 2006")
}
@@ -136,6 +172,9 @@ func scheduledChanges(held entitlements.ListLadderPositionsByPoolRow, pos *plans
if pos.CancelScheduled || !held.DowngradeAt.Valid {
return
}
if pos.Suspended && !held.DowngradeAt.Time.After(now) {
return
}
pos.ScheduledTo = held.DowngradeProductID.UUID.String()
facts.ScheduledName = held.DowngradeProductName.String
facts.ScheduledOn = held.DowngradeAt.Time.Format("Jan 2, 2006")
@@ -287,10 +326,14 @@ func (h *MemberProductsHandler) switchOfferFor(ctx context.Context, orgID, poste
// the named ladder from the organization's position there. The refusal is
// mapped onto the errors the switch already answers with, first match wins:
// a target that is not a tier of the ladder is an unavailable plan whatever
// holds the ladder; a ladder no subscription pays for has no subscription to
// change; the current tier is the tier already held (SwitchPlan resolves the
// subscription before it compares the tier, so the order is the same); and
// anything else is an unavailable plan. A read that fails is returned.
// holds the ladder; a suspended position answers fulfillment.ErrSubscriptionPaused
// when its subscription is paused and fulfillment.ErrSubscriptionSuspended
// otherwise, whatever the target; a ladder no subscription pays for has no
// subscription to change; the current tier is the tier already held
// (SwitchPlan resolves the subscription before it compares the tier, so the
// order is the same); and anything else is an unavailable plan. The member
// gate and a malformed or unknown price id answer before all of them. A read
// that fails is returned.
func (h *MemberProductsHandler) switchOffer(ctx context.Context, orgID, ladderID string, price billing.Price) error {
if err := h.switchTargetOpenToMembers(ctx, price.PriceID); err != nil {
return err
@@ -319,6 +362,14 @@ func (h *MemberProductsHandler) switchOffer(ctx context.Context, orgID, ladderID
return nil
case move.Reason == plans.NotOnLadder:
return errPlanNotOffered
case pos.Suspended:
h.Logger.Info("switch refused: the plan is suspended",
slog.String("ladder_id", ladderID),
slog.String("subscription_status", held[ladderID].SubscriptionStatus.String))
if pos.Paused {
return fulfillment.ErrSubscriptionPaused
}
return fulfillment.ErrSubscriptionSuspended
case !pos.Subscribed:
return fulfillment.ErrNoActivePaidSubscription
case move.Reason == plans.Current:
+12
View File
@@ -28,6 +28,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -57,6 +58,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -88,6 +90,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -117,6 +120,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -148,6 +152,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -187,6 +192,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -216,6 +222,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -253,6 +260,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -282,6 +290,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -311,6 +320,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -340,6 +350,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -369,6 +380,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
+12
View File
@@ -28,6 +28,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -57,6 +58,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -88,6 +90,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -117,6 +120,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -148,6 +152,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -187,6 +192,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -216,6 +222,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -253,6 +260,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -282,6 +290,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -311,6 +320,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -340,6 +350,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -369,6 +380,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -28,6 +28,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -57,6 +58,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": true,
"CanKeep": false,
"CancelBody": "You keep Basic until Jan 15, 2099, then move to Standard.",
@@ -88,6 +90,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -117,6 +120,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -148,6 +152,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -187,6 +192,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -216,6 +222,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -253,6 +260,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -282,6 +290,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -311,6 +320,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -340,6 +350,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -369,6 +380,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -28,6 +28,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "You keep Basic until Jan 15, 2099, then move to Free.",
@@ -57,6 +58,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": true,
"CanKeep": false,
"CancelBody": "You keep Basic until Jan 15, 2099, then move to Free.",
@@ -88,6 +90,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -117,6 +120,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -148,6 +152,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -187,6 +192,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -216,6 +222,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -253,6 +260,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -282,6 +290,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -311,6 +320,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -340,6 +350,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -369,6 +380,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -28,6 +28,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "You keep Plus until Jan 15, 2099, then move to Free on Wiki and leave Duo.",
@@ -57,6 +58,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -88,6 +90,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": true,
"CanKeep": false,
"CancelBody": "You keep Plus until Jan 15, 2099, then move to Free on Wiki and leave Duo.",
@@ -117,6 +120,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -148,6 +152,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -187,6 +192,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": true,
"CanKeep": false,
"CancelBody": "You keep Plus until Jan 15, 2099, then move to Free on Wiki and leave Duo.",
@@ -216,6 +222,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -253,6 +260,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -282,6 +290,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -311,6 +320,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -340,6 +350,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -369,6 +380,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -28,6 +28,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "You keep your current plan until the end of this billing period.",
@@ -57,6 +58,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -88,6 +90,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -117,6 +120,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -148,6 +152,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -187,6 +192,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -216,6 +222,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -253,6 +260,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -282,6 +290,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -311,6 +320,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -340,6 +350,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -369,6 +380,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -28,6 +28,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "You keep Pro until Jan 15, 2099, then move to Free.",
@@ -57,6 +58,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -88,6 +90,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -117,6 +120,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -148,6 +152,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": true,
"CanKeep": false,
"CancelBody": "You keep Pro until Jan 15, 2099, then move to Free.",
@@ -187,6 +192,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -216,6 +222,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -253,6 +260,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -282,6 +290,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -311,6 +320,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -340,6 +350,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -369,6 +380,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -28,6 +28,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -57,6 +58,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -88,6 +90,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -117,6 +120,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -148,6 +152,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "Jan 15, 2099",
"Suspension": "",
"CanCancel": false,
"CanKeep": true,
"CancelBody": "",
@@ -187,6 +192,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -216,6 +222,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -253,6 +260,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -282,6 +290,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -311,6 +320,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -340,6 +350,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -369,6 +380,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -28,6 +28,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -57,6 +58,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -88,6 +90,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -117,6 +120,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -148,6 +152,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "Apr 1, 2099",
"Suspension": "",
"CanCancel": false,
"CanKeep": true,
"CancelBody": "",
@@ -187,6 +192,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -216,6 +222,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -253,6 +260,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -282,6 +290,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -311,6 +320,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -340,6 +350,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -369,6 +380,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -28,6 +28,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "You keep Pro until Jan 15, 2099, then move to Free.",
@@ -57,6 +58,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -88,6 +90,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -117,6 +120,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -148,6 +152,7 @@
"MovesTo": "Standard",
"MovesOn": "Jan 15, 2099",
"EndsOn": "",
"Suspension": "",
"CanCancel": true,
"CanKeep": true,
"CancelBody": "You keep Pro until Jan 15, 2099, then move to Free.",
@@ -187,6 +192,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -216,6 +222,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -253,6 +260,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -282,6 +290,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -311,6 +320,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -340,6 +350,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -369,6 +380,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -28,6 +28,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -57,6 +58,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -88,6 +90,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "You keep Pro until Jan 15, 2099, then move to Plus on Wiki and Duo.",
@@ -117,6 +120,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -148,6 +152,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": true,
"CanKeep": false,
"CancelBody": "You keep Pro until Jan 15, 2099, then move to Plus on Wiki and Duo.",
@@ -187,6 +192,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -216,6 +222,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -253,6 +260,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -282,6 +290,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -311,6 +320,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -340,6 +350,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -369,6 +380,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -0,0 +1,402 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
<span class="badge text-bg-warning">Cancels on Jan 15, 2099</span>
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Free</h4>
</div>
<p class="text-muted small mb-2">Included</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Basic</h4>
</div>
<p class="text-muted small mb-2">$5.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Plus</h4>
</div>
<p class="text-muted small mb-2">$10.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 1 Plan Site</li>
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Standard</h4>
</div>
<p class="text-muted small mb-2">$20.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100 border-primary">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Pro</h4>
<span class="badge text-bg-success">Current plan</span>
</div>
<p class="text-muted small mb-2">$30.00/month, ends Jan 15, 2099</p>
<p class="small text-danger mb-2">Entitlements are suspended until the invoice is paid.</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 10 Plan Sites</li>
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/partials/member/plans/keep" hx-vals='{"ladder_id": "<ladder:Wiki>"}'
hx-target="#member-plans" hx-swap="innerHTML">Keep plan</button>
</div>
</div>
</div>
</div>
</div>
<div class="border-start border-3 ps-3 mb-4 border-secondary-subtle">
<h3 class="fw-bold mb-3 h6 text-muted">
Duo
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Plus</h4>
</div>
<p class="text-muted small mb-2">$10.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 1 Plan Site</li>
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Duo Max</h4>
</div>
<p class="text-muted small mb-2">$40.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Duo Max>"}'
hx-target="#checkout-error-<ladder:Duo>-<product:Duo Max>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Duo>-<product:Duo Max>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
</div>
</div>
<div class="border-start border-3 ps-3 mb-4 border-secondary-subtle">
<h3 class="fw-bold mb-3 h6 text-muted">
Prices
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Entry</h4>
</div>
<p class="text-muted small mb-2">$5.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Entry>"}'
hx-target="#checkout-error-<ladder:Prices>-<product:Entry>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Prices>-<product:Entry>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Once</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Once>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Once>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Unmapped</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Unmapped>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Unmapped>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Elsewhere</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Elsewhere>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Elsewhere>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Bare</h4>
</div>
<p class="text-muted small mb-2">Included</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Bare>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Bare>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,396 @@
{
"Ladders": [
{
"Name": "Wiki",
"SortOrder": 1,
"Tiers": [
{
"ProductID": "<product:Free>",
"Name": "Free",
"Description": "",
"Rank": 0,
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": true,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Basic>",
"Name": "Basic",
"Description": "",
"Rank": 1,
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Basic>",
"Purchasable": true,
"PriceText": "$5.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Plus>",
"Name": "Plus",
"Description": "",
"Rank": 2,
"Relation": "downgrade",
"EntitlementFeatures": [
"1 Plan Site"
],
"MetadataFeatures": null,
"PriceID": "<price:Plus>",
"Purchasable": true,
"PriceText": "$10.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Standard>",
"Name": "Standard",
"Description": "",
"Rank": 3,
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Standard>",
"Purchasable": true,
"PriceText": "$20.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Pro>",
"Name": "Pro",
"Description": "",
"Rank": 4,
"Relation": "current",
"EntitlementFeatures": [
"10 Plan Sites"
],
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "$30.00/month",
"BillingRenewsOn": "Jan 15, 2099",
"BillingPending": false,
"LadderID": "<ladder:Wiki>",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "Jan 15, 2099",
"Suspension": "payment_due",
"CanCancel": false,
"CanKeep": true,
"CancelBody": "",
"Held": false
}
],
"Enrolled": true,
"CancelsOn": "Jan 15, 2099"
},
{
"Name": "Duo",
"SortOrder": 2,
"Tiers": [
{
"ProductID": "<product:Plus>",
"Name": "Plus",
"Description": "",
"Rank": 0,
"Relation": "available",
"EntitlementFeatures": [
"1 Plan Site"
],
"MetadataFeatures": null,
"PriceID": "<price:Plus>",
"Purchasable": true,
"PriceText": "$10.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Duo Max>",
"Name": "Duo Max",
"Description": "",
"Rank": 1,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Duo Max>",
"Purchasable": true,
"PriceText": "$40.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Duo>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
}
],
"Enrolled": false,
"CancelsOn": ""
},
{
"Name": "Prices",
"SortOrder": 3,
"Tiers": [
{
"ProductID": "<product:Entry>",
"Name": "Entry",
"Description": "",
"Rank": 0,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Entry>",
"Purchasable": true,
"PriceText": "$5.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Once>",
"Name": "Once",
"Description": "",
"Rank": 1,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Once>",
"Purchasable": true,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Unmapped>",
"Name": "Unmapped",
"Description": "",
"Rank": 2,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Unmapped>",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Elsewhere>",
"Name": "Elsewhere",
"Description": "",
"Rank": 3,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Elsewhere>",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Bare>",
"Name": "Bare",
"Description": "",
"Rank": 4,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": true,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
}
],
"Enrolled": false,
"CancelsOn": ""
}
],
"Error": "",
"Success": ""
}
@@ -0,0 +1,427 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Free</h4>
</div>
<p class="text-muted small mb-2">Included</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-auto"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/member/plans/cancel"
data-action-method="post"
data-action-target="#member-plans"
data-action-title="Cancel plan"
data-action-body="Pro ends on Jan 15, 2099, then you move to Free. The unpaid invoice stays due."
data-action-confirm-label="Cancel plan"
data-action-style="warning"
data-action-fields='{"ladder_id": "<ladder:Wiki>"}'>Downgrade</button>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Basic</h4>
</div>
<p class="text-muted small mb-2">$5.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Plus</h4>
</div>
<p class="text-muted small mb-2">$10.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 1 Plan Site</li>
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Standard</h4>
</div>
<p class="text-muted small mb-2">$20.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100 border-primary">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Pro</h4>
<span class="badge text-bg-success">Current plan</span>
</div>
<p class="text-muted small mb-2">$30.00/month, payment due</p>
<p class="small text-danger mb-2">Entitlements are suspended until the invoice is paid.</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 10 Plan Sites</li>
</ul>
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-auto"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/member/plans/cancel"
data-action-method="post"
data-action-target="#member-plans"
data-action-title="Cancel plan"
data-action-body="Pro ends on Jan 15, 2099, then you move to Free. The unpaid invoice stays due."
data-action-confirm-label="Cancel plan"
data-action-style="warning"
data-action-fields='{"ladder_id": "<ladder:Wiki>"}'>Cancel plan</button>
</div>
</div>
</div>
</div>
</div>
<div class="border-start border-3 ps-3 mb-4 border-secondary-subtle">
<h3 class="fw-bold mb-3 h6 text-muted">
Duo
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Plus</h4>
</div>
<p class="text-muted small mb-2">$10.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 1 Plan Site</li>
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Duo Max</h4>
</div>
<p class="text-muted small mb-2">$40.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Duo Max>"}'
hx-target="#checkout-error-<ladder:Duo>-<product:Duo Max>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Duo>-<product:Duo Max>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
</div>
</div>
<div class="border-start border-3 ps-3 mb-4 border-secondary-subtle">
<h3 class="fw-bold mb-3 h6 text-muted">
Prices
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Entry</h4>
</div>
<p class="text-muted small mb-2">$5.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Entry>"}'
hx-target="#checkout-error-<ladder:Prices>-<product:Entry>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Prices>-<product:Entry>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Once</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Once>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Once>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Unmapped</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Unmapped>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Unmapped>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Elsewhere</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Elsewhere>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Elsewhere>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Bare</h4>
</div>
<p class="text-muted small mb-2">Included</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Bare>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Bare>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,396 @@
{
"Ladders": [
{
"Name": "Wiki",
"SortOrder": 1,
"Tiers": [
{
"ProductID": "<product:Free>",
"Name": "Free",
"Description": "",
"Rank": 0,
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": true,
"MoveEnabled": true,
"MoveKind": "cancel",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Downgrade",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "Pro ends on Jan 15, 2099, then you move to Free. The unpaid invoice stays due.",
"Held": false
},
{
"ProductID": "<product:Basic>",
"Name": "Basic",
"Description": "",
"Rank": 1,
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Basic>",
"Purchasable": true,
"PriceText": "$5.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Plus>",
"Name": "Plus",
"Description": "",
"Rank": 2,
"Relation": "downgrade",
"EntitlementFeatures": [
"1 Plan Site"
],
"MetadataFeatures": null,
"PriceID": "<price:Plus>",
"Purchasable": true,
"PriceText": "$10.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Standard>",
"Name": "Standard",
"Description": "",
"Rank": 3,
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Standard>",
"Purchasable": true,
"PriceText": "$20.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Pro>",
"Name": "Pro",
"Description": "",
"Rank": 4,
"Relation": "current",
"EntitlementFeatures": [
"10 Plan Sites"
],
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "$30.00/month",
"BillingRenewsOn": "Jan 15, 2099",
"BillingPending": false,
"LadderID": "<ladder:Wiki>",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "payment_due",
"CanCancel": true,
"CanKeep": false,
"CancelBody": "Pro ends on Jan 15, 2099, then you move to Free. The unpaid invoice stays due.",
"Held": false
}
],
"Enrolled": true,
"CancelsOn": ""
},
{
"Name": "Duo",
"SortOrder": 2,
"Tiers": [
{
"ProductID": "<product:Plus>",
"Name": "Plus",
"Description": "",
"Rank": 0,
"Relation": "available",
"EntitlementFeatures": [
"1 Plan Site"
],
"MetadataFeatures": null,
"PriceID": "<price:Plus>",
"Purchasable": true,
"PriceText": "$10.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Duo Max>",
"Name": "Duo Max",
"Description": "",
"Rank": 1,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Duo Max>",
"Purchasable": true,
"PriceText": "$40.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Duo>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
}
],
"Enrolled": false,
"CancelsOn": ""
},
{
"Name": "Prices",
"SortOrder": 3,
"Tiers": [
{
"ProductID": "<product:Entry>",
"Name": "Entry",
"Description": "",
"Rank": 0,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Entry>",
"Purchasable": true,
"PriceText": "$5.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Once>",
"Name": "Once",
"Description": "",
"Rank": 1,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Once>",
"Purchasable": true,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Unmapped>",
"Name": "Unmapped",
"Description": "",
"Rank": 2,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Unmapped>",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Elsewhere>",
"Name": "Elsewhere",
"Description": "",
"Rank": 3,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Elsewhere>",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Bare>",
"Name": "Bare",
"Description": "",
"Rank": 4,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": true,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
}
],
"Enrolled": false,
"CancelsOn": ""
}
],
"Error": "",
"Success": ""
}
@@ -0,0 +1,431 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Free</h4>
</div>
<p class="text-muted small mb-2">Included</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-auto"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/member/plans/cancel"
data-action-method="post"
data-action-target="#member-plans"
data-action-title="Cancel plan"
data-action-body="Pro ends on Jan 15, 2099, then you move to Free. The unpaid invoice stays due."
data-action-confirm-label="Cancel plan"
data-action-style="warning"
data-action-fields='{"ladder_id": "<ladder:Wiki>"}'>Downgrade</button>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Basic</h4>
</div>
<p class="text-muted small mb-2">$5.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Plus</h4>
</div>
<p class="text-muted small mb-2">$10.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 1 Plan Site</li>
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Standard</h4>
</div>
<p class="text-muted small mb-2">$20.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100 border-primary">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Pro</h4>
<span class="badge text-bg-success">Current plan</span>
</div>
<p class="text-muted small mb-2">$30.00/month, moves to Standard on Jan 15, 2099</p>
<p class="small text-danger mb-2">Entitlements are suspended until the invoice is paid.</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 10 Plan Sites</li>
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/partials/member/plans/keep" hx-vals='{"ladder_id": "<ladder:Wiki>"}'
hx-target="#member-plans" hx-swap="innerHTML">Keep plan</button>
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-2"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/member/plans/cancel"
data-action-method="post"
data-action-target="#member-plans"
data-action-title="Cancel plan"
data-action-body="Pro ends on Jan 15, 2099, then you move to Free. The unpaid invoice stays due."
data-action-confirm-label="Cancel plan"
data-action-style="warning"
data-action-fields='{"ladder_id": "<ladder:Wiki>"}'>Cancel plan</button>
</div>
</div>
</div>
</div>
</div>
<div class="border-start border-3 ps-3 mb-4 border-secondary-subtle">
<h3 class="fw-bold mb-3 h6 text-muted">
Duo
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Plus</h4>
</div>
<p class="text-muted small mb-2">$10.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 1 Plan Site</li>
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Duo Max</h4>
</div>
<p class="text-muted small mb-2">$40.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Duo Max>"}'
hx-target="#checkout-error-<ladder:Duo>-<product:Duo Max>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Duo>-<product:Duo Max>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
</div>
</div>
<div class="border-start border-3 ps-3 mb-4 border-secondary-subtle">
<h3 class="fw-bold mb-3 h6 text-muted">
Prices
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Entry</h4>
</div>
<p class="text-muted small mb-2">$5.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Entry>"}'
hx-target="#checkout-error-<ladder:Prices>-<product:Entry>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Prices>-<product:Entry>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Once</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Once>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Once>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Unmapped</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Unmapped>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Unmapped>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Elsewhere</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Elsewhere>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Elsewhere>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Bare</h4>
</div>
<p class="text-muted small mb-2">Included</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Bare>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Bare>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,396 @@
{
"Ladders": [
{
"Name": "Wiki",
"SortOrder": 1,
"Tiers": [
{
"ProductID": "<product:Free>",
"Name": "Free",
"Description": "",
"Rank": 0,
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": true,
"MoveEnabled": true,
"MoveKind": "cancel",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Downgrade",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "Pro ends on Jan 15, 2099, then you move to Free. The unpaid invoice stays due.",
"Held": false
},
{
"ProductID": "<product:Basic>",
"Name": "Basic",
"Description": "",
"Rank": 1,
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Basic>",
"Purchasable": true,
"PriceText": "$5.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Plus>",
"Name": "Plus",
"Description": "",
"Rank": 2,
"Relation": "downgrade",
"EntitlementFeatures": [
"1 Plan Site"
],
"MetadataFeatures": null,
"PriceID": "<price:Plus>",
"Purchasable": true,
"PriceText": "$10.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Standard>",
"Name": "Standard",
"Description": "",
"Rank": 3,
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Standard>",
"Purchasable": true,
"PriceText": "$20.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Pro>",
"Name": "Pro",
"Description": "",
"Rank": 4,
"Relation": "current",
"EntitlementFeatures": [
"10 Plan Sites"
],
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "$30.00/month",
"BillingRenewsOn": "Jan 15, 2099",
"BillingPending": false,
"LadderID": "<ladder:Wiki>",
"MovesTo": "Standard",
"MovesOn": "Jan 15, 2099",
"EndsOn": "",
"Suspension": "payment_due",
"CanCancel": true,
"CanKeep": true,
"CancelBody": "Pro ends on Jan 15, 2099, then you move to Free. The unpaid invoice stays due.",
"Held": false
}
],
"Enrolled": true,
"CancelsOn": ""
},
{
"Name": "Duo",
"SortOrder": 2,
"Tiers": [
{
"ProductID": "<product:Plus>",
"Name": "Plus",
"Description": "",
"Rank": 0,
"Relation": "available",
"EntitlementFeatures": [
"1 Plan Site"
],
"MetadataFeatures": null,
"PriceID": "<price:Plus>",
"Purchasable": true,
"PriceText": "$10.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Duo Max>",
"Name": "Duo Max",
"Description": "",
"Rank": 1,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Duo Max>",
"Purchasable": true,
"PriceText": "$40.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Duo>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
}
],
"Enrolled": false,
"CancelsOn": ""
},
{
"Name": "Prices",
"SortOrder": 3,
"Tiers": [
{
"ProductID": "<product:Entry>",
"Name": "Entry",
"Description": "",
"Rank": 0,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Entry>",
"Purchasable": true,
"PriceText": "$5.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Once>",
"Name": "Once",
"Description": "",
"Rank": 1,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Once>",
"Purchasable": true,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Unmapped>",
"Name": "Unmapped",
"Description": "",
"Rank": 2,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Unmapped>",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Elsewhere>",
"Name": "Elsewhere",
"Description": "",
"Rank": 3,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Elsewhere>",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Bare>",
"Name": "Bare",
"Description": "",
"Rank": 4,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": true,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
}
],
"Enrolled": false,
"CancelsOn": ""
}
],
"Error": "",
"Success": ""
}
@@ -5,8 +5,8 @@
<div class="border-start border-3 ps-3 mb-4 border-secondary-subtle">
<h3 class="fw-bold mb-3 h6 text-muted">
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
</h3>
@@ -58,16 +58,6 @@
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Basic>"}'
hx-target="#checkout-error-<ladder:Wiki>-<product:Basic>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Wiki>-<product:Basic>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
@@ -81,7 +71,7 @@
</div>
<p class="text-muted small mb-2">$10.00/month</p>
<p class="text-muted small mb-2">Granted</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
@@ -97,10 +87,17 @@
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Plus>"}'
hx-target="#checkout-error-<ladder:Wiki>-<product:Plus>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Wiki>-<product:Plus>" class="small text-danger mt-1"></div>
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-auto"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/member/plans/cancel"
data-action-method="post"
data-action-target="#member-plans"
data-action-title="Cancel plan"
data-action-body="Pro ends on Jan 15, 2099, then you move to Plus on Wiki and Duo. The unpaid invoice stays due."
data-action-confirm-label="Cancel plan"
data-action-style="warning"
data-action-fields='{"ladder_id": "<ladder:Wiki>"}'>Downgrade</button>
@@ -130,30 +127,23 @@
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Standard>"}'
hx-target="#checkout-error-<ladder:Wiki>-<product:Standard>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Wiki>-<product:Standard>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card h-100 border-primary">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Pro</h4>
<span class="badge text-bg-success">Current plan</span>
</div>
<p class="text-muted small mb-2">$30.00/month</p>
<p class="text-muted small mb-2">$30.00/month, payment due</p>
<p class="small text-danger mb-2">Entitlements are suspended until the invoice is paid.</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
@@ -166,14 +156,17 @@
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Pro>"}'
hx-target="#checkout-error-<ladder:Wiki>-<product:Pro>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Wiki>-<product:Pro>" class="small text-danger mt-1"></div>
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-auto"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/member/plans/cancel"
data-action-method="post"
data-action-target="#member-plans"
data-action-title="Cancel plan"
data-action-body="Pro ends on Jan 15, 2099, then you move to Plus on Wiki and Duo. The unpaid invoice stays due."
data-action-confirm-label="Cancel plan"
data-action-style="warning"
data-action-fields='{"ladder_id": "<ladder:Wiki>"}'>Cancel plan</button>
@@ -214,16 +207,6 @@
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Plus>"}'
hx-target="#checkout-error-<ladder:Duo>-<product:Plus>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Duo>-<product:Plus>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
@@ -9,7 +9,7 @@
"Name": "Free",
"Description": "",
"Rank": 0,
"Relation": "available",
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "",
@@ -28,6 +28,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -38,17 +39,17 @@
"Name": "Basic",
"Description": "",
"Rank": 1,
"Relation": "available",
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Basic>",
"Purchasable": true,
"PriceText": "$5.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Subscribe",
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
@@ -57,6 +58,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -67,7 +69,7 @@
"Name": "Plus",
"Description": "",
"Rank": 2,
"Relation": "available",
"Relation": "downgrade",
"EntitlementFeatures": [
"1 Plan Site"
],
@@ -77,9 +79,9 @@
"PriceText": "$10.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveKind": "cancel",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Subscribe",
"MoveLabel": "Downgrade",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
@@ -88,27 +90,28 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
"CancelBody": "Pro ends on Jan 15, 2099, then you move to Plus on Wiki and Duo. The unpaid invoice stays due.",
"Held": true
},
{
"ProductID": "<product:Standard>",
"Name": "Standard",
"Description": "",
"Rank": 3,
"Relation": "available",
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Standard>",
"Purchasable": true,
"PriceText": "$20.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Subscribe",
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
@@ -117,6 +120,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -127,34 +131,35 @@
"Name": "Pro",
"Description": "",
"Rank": 4,
"Relation": "available",
"Relation": "current",
"EntitlementFeatures": [
"10 Plan Sites"
],
"MetadataFeatures": null,
"PriceID": "<price:Pro>",
"Purchasable": true,
"PriceText": "$30.00/month",
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Subscribe",
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPriceText": "$30.00/month",
"BillingRenewsOn": "Jan 15, 2099",
"BillingPending": false,
"LadderID": "",
"LadderID": "<ladder:Wiki>",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"Suspension": "payment_due",
"CanCancel": true,
"CanKeep": false,
"CancelBody": "",
"CancelBody": "Pro ends on Jan 15, 2099, then you move to Plus on Wiki and Duo. The unpaid invoice stays due.",
"Held": false
}
],
"Enrolled": false,
"Enrolled": true,
"CancelsOn": ""
},
{
@@ -175,10 +180,10 @@
"Purchasable": true,
"PriceText": "$10.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Duo>",
"MoveLabel": "Subscribe",
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
@@ -187,6 +192,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -216,6 +222,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -253,6 +260,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -282,6 +290,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -311,6 +320,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -340,6 +350,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -369,6 +380,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -0,0 +1,410 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Free</h4>
</div>
<p class="text-muted small mb-2">Included</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Basic</h4>
</div>
<p class="text-muted small mb-2">$5.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Plus</h4>
</div>
<p class="text-muted small mb-2">$10.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 1 Plan Site</li>
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Standard</h4>
</div>
<p class="text-muted small mb-2">$20.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100 border-primary">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Pro</h4>
<span class="badge text-bg-success">Current plan</span>
</div>
<p class="text-muted small mb-2">$30.00/month, payment due</p>
<p class="small text-danger mb-2">Entitlements are suspended until the invoice is paid.</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 10 Plan Sites</li>
</ul>
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-auto"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/member/plans/cancel"
data-action-method="post"
data-action-target="#member-plans"
data-action-title="Cancel plan"
data-action-body="Pro ends on Jan 15, 2099. The unpaid invoice stays due."
data-action-confirm-label="Cancel plan"
data-action-style="warning"
data-action-fields='{"ladder_id": "<ladder:Wiki>"}'>Cancel plan</button>
</div>
</div>
</div>
</div>
</div>
<div class="border-start border-3 ps-3 mb-4 border-secondary-subtle">
<h3 class="fw-bold mb-3 h6 text-muted">
Duo
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Plus</h4>
</div>
<p class="text-muted small mb-2">$10.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 1 Plan Site</li>
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Duo Max</h4>
</div>
<p class="text-muted small mb-2">$40.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Duo Max>"}'
hx-target="#checkout-error-<ladder:Duo>-<product:Duo Max>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Duo>-<product:Duo Max>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
</div>
</div>
<div class="border-start border-3 ps-3 mb-4 border-secondary-subtle">
<h3 class="fw-bold mb-3 h6 text-muted">
Prices
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Entry</h4>
</div>
<p class="text-muted small mb-2">$5.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Entry>"}'
hx-target="#checkout-error-<ladder:Prices>-<product:Entry>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Prices>-<product:Entry>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Once</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Once>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Once>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Unmapped</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Unmapped>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Unmapped>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Elsewhere</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Elsewhere>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Elsewhere>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Bare</h4>
</div>
<p class="text-muted small mb-2">Included</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Bare>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Bare>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,396 @@
{
"Ladders": [
{
"Name": "Wiki",
"SortOrder": 1,
"Tiers": [
{
"ProductID": "<product:Free>",
"Name": "Free",
"Description": "",
"Rank": 0,
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": true,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Basic>",
"Name": "Basic",
"Description": "",
"Rank": 1,
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Basic>",
"Purchasable": true,
"PriceText": "$5.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Plus>",
"Name": "Plus",
"Description": "",
"Rank": 2,
"Relation": "downgrade",
"EntitlementFeatures": [
"1 Plan Site"
],
"MetadataFeatures": null,
"PriceID": "<price:Plus>",
"Purchasable": true,
"PriceText": "$10.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Standard>",
"Name": "Standard",
"Description": "",
"Rank": 3,
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Standard>",
"Purchasable": true,
"PriceText": "$20.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Pro>",
"Name": "Pro",
"Description": "",
"Rank": 4,
"Relation": "current",
"EntitlementFeatures": [
"10 Plan Sites"
],
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "$30.00/month",
"BillingRenewsOn": "Jan 15, 2099",
"BillingPending": false,
"LadderID": "<ladder:Wiki>",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "payment_due",
"CanCancel": true,
"CanKeep": false,
"CancelBody": "Pro ends on Jan 15, 2099. The unpaid invoice stays due.",
"Held": false
}
],
"Enrolled": true,
"CancelsOn": ""
},
{
"Name": "Duo",
"SortOrder": 2,
"Tiers": [
{
"ProductID": "<product:Plus>",
"Name": "Plus",
"Description": "",
"Rank": 0,
"Relation": "available",
"EntitlementFeatures": [
"1 Plan Site"
],
"MetadataFeatures": null,
"PriceID": "<price:Plus>",
"Purchasable": true,
"PriceText": "$10.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Duo Max>",
"Name": "Duo Max",
"Description": "",
"Rank": 1,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Duo Max>",
"Purchasable": true,
"PriceText": "$40.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Duo>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
}
],
"Enrolled": false,
"CancelsOn": ""
},
{
"Name": "Prices",
"SortOrder": 3,
"Tiers": [
{
"ProductID": "<product:Entry>",
"Name": "Entry",
"Description": "",
"Rank": 0,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Entry>",
"Purchasable": true,
"PriceText": "$5.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Once>",
"Name": "Once",
"Description": "",
"Rank": 1,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Once>",
"Purchasable": true,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Unmapped>",
"Name": "Unmapped",
"Description": "",
"Rank": 2,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Unmapped>",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Elsewhere>",
"Name": "Elsewhere",
"Description": "",
"Rank": 3,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Elsewhere>",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Bare>",
"Name": "Bare",
"Description": "",
"Rank": 4,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": true,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
}
],
"Enrolled": false,
"CancelsOn": ""
}
],
"Error": "",
"Success": ""
}
+36 -53
View File
@@ -5,8 +5,8 @@
<div class="border-start border-3 ps-3 mb-4 border-secondary-subtle">
<h3 class="fw-bold mb-3 h6 text-muted">
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
</h3>
@@ -33,6 +33,23 @@
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-auto"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/member/plans/cancel"
data-action-method="post"
data-action-target="#member-plans"
data-action-title="Cancel plan"
data-action-body="Pro ends on Jan 15, 2099, then you move to Free. The unpaid invoice stays due."
data-action-confirm-label="Cancel plan"
data-action-style="warning"
data-action-fields='{"ladder_id": "<ladder:Wiki>"}'>Downgrade</button>
</div>
</div>
</div>
@@ -58,16 +75,6 @@
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Basic>"}'
hx-target="#checkout-error-<ladder:Wiki>-<product:Basic>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Wiki>-<product:Basic>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
@@ -95,16 +102,6 @@
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Plus>"}'
hx-target="#checkout-error-<ladder:Wiki>-<product:Plus>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Wiki>-<product:Plus>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
@@ -130,30 +127,23 @@
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Standard>"}'
hx-target="#checkout-error-<ladder:Wiki>-<product:Standard>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Wiki>-<product:Standard>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card h-100 border-primary">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Pro</h4>
<span class="badge text-bg-success">Current plan</span>
</div>
<p class="text-muted small mb-2">$30.00/month</p>
<p class="text-muted small mb-2">$30.00/month, payment due</p>
<p class="small text-danger mb-2">Entitlements are suspended until the invoice is paid.</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
@@ -166,14 +156,17 @@
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Pro>"}'
hx-target="#checkout-error-<ladder:Wiki>-<product:Pro>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Wiki>-<product:Pro>" class="small text-danger mt-1"></div>
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-auto"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/member/plans/cancel"
data-action-method="post"
data-action-target="#member-plans"
data-action-title="Cancel plan"
data-action-body="Pro ends on Jan 15, 2099, then you move to Free. The unpaid invoice stays due."
data-action-confirm-label="Cancel plan"
data-action-style="warning"
data-action-fields='{"ladder_id": "<ladder:Wiki>"}'>Cancel plan</button>
@@ -214,16 +207,6 @@
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Plus>"}'
hx-target="#checkout-error-<ladder:Duo>-<product:Plus>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Duo>-<product:Plus>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
+70 -58
View File
@@ -9,13 +9,43 @@
"Name": "Free",
"Description": "",
"Rank": 0,
"Relation": "available",
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": true,
"MoveEnabled": true,
"MoveKind": "cancel",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Downgrade",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "Pro ends on Jan 15, 2099, then you move to Free. The unpaid invoice stays due.",
"Held": false
},
{
"ProductID": "<product:Basic>",
"Name": "Basic",
"Description": "",
"Rank": 1,
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Basic>",
"Purchasable": true,
"PriceText": "$5.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
@@ -28,35 +58,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Basic>",
"Name": "Basic",
"Description": "",
"Rank": 1,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Basic>",
"Purchasable": true,
"PriceText": "$5.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -67,7 +69,7 @@
"Name": "Plus",
"Description": "",
"Rank": 2,
"Relation": "available",
"Relation": "downgrade",
"EntitlementFeatures": [
"1 Plan Site"
],
@@ -76,10 +78,10 @@
"Purchasable": true,
"PriceText": "$10.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Subscribe",
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
@@ -88,6 +90,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -98,17 +101,17 @@
"Name": "Standard",
"Description": "",
"Rank": 3,
"Relation": "available",
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Standard>",
"Purchasable": true,
"PriceText": "$20.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Subscribe",
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
@@ -117,6 +120,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -127,34 +131,35 @@
"Name": "Pro",
"Description": "",
"Rank": 4,
"Relation": "available",
"Relation": "current",
"EntitlementFeatures": [
"10 Plan Sites"
],
"MetadataFeatures": null,
"PriceID": "<price:Pro>",
"Purchasable": true,
"PriceText": "$30.00/month",
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Subscribe",
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPriceText": "$30.00/month",
"BillingRenewsOn": "Jan 15, 2099",
"BillingPending": false,
"LadderID": "",
"LadderID": "<ladder:Wiki>",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"CanCancel": false,
"Suspension": "payment_due",
"CanCancel": true,
"CanKeep": false,
"CancelBody": "",
"CancelBody": "Pro ends on Jan 15, 2099, then you move to Free. The unpaid invoice stays due.",
"Held": false
}
],
"Enrolled": false,
"Enrolled": true,
"CancelsOn": ""
},
{
@@ -175,10 +180,10 @@
"Purchasable": true,
"PriceText": "$10.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Duo>",
"MoveLabel": "Subscribe",
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
@@ -187,6 +192,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -216,6 +222,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -253,6 +260,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -282,6 +290,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -311,6 +320,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -340,6 +350,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -369,6 +380,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -0,0 +1,398 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Free</h4>
</div>
<p class="text-muted small mb-2">Included</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Basic</h4>
</div>
<p class="text-muted small mb-2">$5.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Plus</h4>
</div>
<p class="text-muted small mb-2">$10.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 1 Plan Site</li>
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Standard</h4>
</div>
<p class="text-muted small mb-2">$20.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100 border-primary">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Pro</h4>
<span class="badge text-bg-success">Current plan</span>
</div>
<p class="text-muted small mb-2">$30.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">
<li class="mb-1">&middot; 10 Plan Sites</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="border-start border-3 ps-3 mb-4 border-secondary-subtle">
<h3 class="fw-bold mb-3 h6 text-muted">
Duo
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Plus</h4>
</div>
<p class="text-muted small mb-2">$10.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 1 Plan Site</li>
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Duo Max</h4>
</div>
<p class="text-muted small mb-2">$40.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Duo Max>"}'
hx-target="#checkout-error-<ladder:Duo>-<product:Duo Max>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Duo>-<product:Duo Max>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
</div>
</div>
<div class="border-start border-3 ps-3 mb-4 border-secondary-subtle">
<h3 class="fw-bold mb-3 h6 text-muted">
Prices
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Entry</h4>
</div>
<p class="text-muted small mb-2">$5.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Entry>"}'
hx-target="#checkout-error-<ladder:Prices>-<product:Entry>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Prices>-<product:Entry>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Once</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Once>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Once>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Unmapped</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Unmapped>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Unmapped>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Elsewhere</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Elsewhere>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Elsewhere>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Bare</h4>
</div>
<p class="text-muted small mb-2">Included</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Bare>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Bare>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,396 @@
{
"Ladders": [
{
"Name": "Wiki",
"SortOrder": 1,
"Tiers": [
{
"ProductID": "<product:Free>",
"Name": "Free",
"Description": "",
"Rank": 0,
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": true,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Basic>",
"Name": "Basic",
"Description": "",
"Rank": 1,
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Basic>",
"Purchasable": true,
"PriceText": "$5.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Plus>",
"Name": "Plus",
"Description": "",
"Rank": 2,
"Relation": "downgrade",
"EntitlementFeatures": [
"1 Plan Site"
],
"MetadataFeatures": null,
"PriceID": "<price:Plus>",
"Purchasable": true,
"PriceText": "$10.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Standard>",
"Name": "Standard",
"Description": "",
"Rank": 3,
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Standard>",
"Purchasable": true,
"PriceText": "$20.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Pro>",
"Name": "Pro",
"Description": "",
"Rank": 4,
"Relation": "current",
"EntitlementFeatures": [
"10 Plan Sites"
],
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "$30.00/month",
"BillingRenewsOn": "Jan 15, 2099",
"BillingPending": false,
"LadderID": "<ladder:Wiki>",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "paused",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
}
],
"Enrolled": true,
"CancelsOn": ""
},
{
"Name": "Duo",
"SortOrder": 2,
"Tiers": [
{
"ProductID": "<product:Plus>",
"Name": "Plus",
"Description": "",
"Rank": 0,
"Relation": "available",
"EntitlementFeatures": [
"1 Plan Site"
],
"MetadataFeatures": null,
"PriceID": "<price:Plus>",
"Purchasable": true,
"PriceText": "$10.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Duo Max>",
"Name": "Duo Max",
"Description": "",
"Rank": 1,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Duo Max>",
"Purchasable": true,
"PriceText": "$40.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Duo>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
}
],
"Enrolled": false,
"CancelsOn": ""
},
{
"Name": "Prices",
"SortOrder": 3,
"Tiers": [
{
"ProductID": "<product:Entry>",
"Name": "Entry",
"Description": "",
"Rank": 0,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Entry>",
"Purchasable": true,
"PriceText": "$5.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Once>",
"Name": "Once",
"Description": "",
"Rank": 1,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Once>",
"Purchasable": true,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Unmapped>",
"Name": "Unmapped",
"Description": "",
"Rank": 2,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Unmapped>",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Elsewhere>",
"Name": "Elsewhere",
"Description": "",
"Rank": 3,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Elsewhere>",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Bare>",
"Name": "Bare",
"Description": "",
"Rank": 4,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": true,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
}
],
"Enrolled": false,
"CancelsOn": ""
}
],
"Error": "",
"Success": ""
}
@@ -0,0 +1,427 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Free</h4>
</div>
<p class="text-muted small mb-2">Included</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-auto"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/member/plans/cancel"
data-action-method="post"
data-action-target="#member-plans"
data-action-title="Cancel plan"
data-action-body="Pro ends on Jan 15, 2099, then you move to Free. The unpaid invoice stays due."
data-action-confirm-label="Cancel plan"
data-action-style="warning"
data-action-fields='{"ladder_id": "<ladder:Wiki>"}'>Downgrade</button>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Basic</h4>
</div>
<p class="text-muted small mb-2">$5.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Plus</h4>
</div>
<p class="text-muted small mb-2">$10.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 1 Plan Site</li>
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Standard</h4>
</div>
<p class="text-muted small mb-2">$20.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100 border-primary">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Pro</h4>
<span class="badge text-bg-success">Current plan</span>
</div>
<p class="text-muted small mb-2">$30.00/month, payment due</p>
<p class="small text-danger mb-2">Entitlements are suspended until the invoice is paid.</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 10 Plan Sites</li>
</ul>
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-auto"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/member/plans/cancel"
data-action-method="post"
data-action-target="#member-plans"
data-action-title="Cancel plan"
data-action-body="Pro ends on Jan 15, 2099, then you move to Free. The unpaid invoice stays due."
data-action-confirm-label="Cancel plan"
data-action-style="warning"
data-action-fields='{"ladder_id": "<ladder:Wiki>"}'>Cancel plan</button>
</div>
</div>
</div>
</div>
</div>
<div class="border-start border-3 ps-3 mb-4 border-secondary-subtle">
<h3 class="fw-bold mb-3 h6 text-muted">
Duo
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Plus</h4>
</div>
<p class="text-muted small mb-2">$10.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 1 Plan Site</li>
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Duo Max</h4>
</div>
<p class="text-muted small mb-2">$40.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Duo Max>"}'
hx-target="#checkout-error-<ladder:Duo>-<product:Duo Max>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Duo>-<product:Duo Max>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
</div>
</div>
<div class="border-start border-3 ps-3 mb-4 border-secondary-subtle">
<h3 class="fw-bold mb-3 h6 text-muted">
Prices
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Entry</h4>
</div>
<p class="text-muted small mb-2">$5.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Entry>"}'
hx-target="#checkout-error-<ladder:Prices>-<product:Entry>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Prices>-<product:Entry>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Once</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Once>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Once>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Unmapped</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Unmapped>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Unmapped>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Elsewhere</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Elsewhere>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Elsewhere>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Bare</h4>
</div>
<p class="text-muted small mb-2">Included</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Bare>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Bare>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,396 @@
{
"Ladders": [
{
"Name": "Wiki",
"SortOrder": 1,
"Tiers": [
{
"ProductID": "<product:Free>",
"Name": "Free",
"Description": "",
"Rank": 0,
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": true,
"MoveEnabled": true,
"MoveKind": "cancel",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Downgrade",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "Pro ends on Jan 15, 2099, then you move to Free. The unpaid invoice stays due.",
"Held": false
},
{
"ProductID": "<product:Basic>",
"Name": "Basic",
"Description": "",
"Rank": 1,
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Basic>",
"Purchasable": true,
"PriceText": "$5.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Plus>",
"Name": "Plus",
"Description": "",
"Rank": 2,
"Relation": "downgrade",
"EntitlementFeatures": [
"1 Plan Site"
],
"MetadataFeatures": null,
"PriceID": "<price:Plus>",
"Purchasable": true,
"PriceText": "$10.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Standard>",
"Name": "Standard",
"Description": "",
"Rank": 3,
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Standard>",
"Purchasable": true,
"PriceText": "$20.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Pro>",
"Name": "Pro",
"Description": "",
"Rank": 4,
"Relation": "current",
"EntitlementFeatures": [
"10 Plan Sites"
],
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "$30.00/month",
"BillingRenewsOn": "Jan 15, 2099",
"BillingPending": false,
"LadderID": "<ladder:Wiki>",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "payment_due",
"CanCancel": true,
"CanKeep": false,
"CancelBody": "Pro ends on Jan 15, 2099, then you move to Free. The unpaid invoice stays due.",
"Held": false
}
],
"Enrolled": true,
"CancelsOn": ""
},
{
"Name": "Duo",
"SortOrder": 2,
"Tiers": [
{
"ProductID": "<product:Plus>",
"Name": "Plus",
"Description": "",
"Rank": 0,
"Relation": "available",
"EntitlementFeatures": [
"1 Plan Site"
],
"MetadataFeatures": null,
"PriceID": "<price:Plus>",
"Purchasable": true,
"PriceText": "$10.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Duo Max>",
"Name": "Duo Max",
"Description": "",
"Rank": 1,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Duo Max>",
"Purchasable": true,
"PriceText": "$40.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Duo>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
}
],
"Enrolled": false,
"CancelsOn": ""
},
{
"Name": "Prices",
"SortOrder": 3,
"Tiers": [
{
"ProductID": "<product:Entry>",
"Name": "Entry",
"Description": "",
"Rank": 0,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Entry>",
"Purchasable": true,
"PriceText": "$5.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Once>",
"Name": "Once",
"Description": "",
"Rank": 1,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Once>",
"Purchasable": true,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Unmapped>",
"Name": "Unmapped",
"Description": "",
"Rank": 2,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Unmapped>",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Elsewhere>",
"Name": "Elsewhere",
"Description": "",
"Rank": 3,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Elsewhere>",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Bare>",
"Name": "Bare",
"Description": "",
"Rank": 4,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": true,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
}
],
"Enrolled": false,
"CancelsOn": ""
}
],
"Error": "",
"Success": ""
}
@@ -28,6 +28,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -57,6 +58,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -88,6 +90,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -117,6 +120,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -148,6 +152,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -187,6 +192,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -216,6 +222,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -253,6 +260,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -282,6 +290,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -311,6 +320,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -340,6 +350,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -369,6 +380,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -398,6 +410,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -0,0 +1,409 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Free</h4>
</div>
<p class="text-muted small mb-2">Included</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-auto"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/member/plans/cancel"
data-action-method="post"
data-action-target="#member-plans"
data-action-title="Cancel plan"
data-action-body="Plus ends with this billing period. The unpaid invoice stays due."
data-action-confirm-label="Cancel plan"
data-action-style="warning"
data-action-fields='{"ladder_id": "<ladder:Wiki>"}'>Downgrade</button>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Basic</h4>
</div>
<p class="text-muted small mb-2">$5.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100 border-primary">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Plus</h4>
<span class="badge text-bg-success">Current plan</span>
</div>
<p class="text-muted small mb-2">Billing details are still being processed.</p>
<p class="small text-danger mb-2">Entitlements are suspended until the invoice is paid.</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 1 Plan Site</li>
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Standard</h4>
</div>
<p class="text-muted small mb-2">$20.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Pro</h4>
</div>
<p class="text-muted small mb-2">$30.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 10 Plan Sites</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Duo
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100 border-primary">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Plus</h4>
<span class="badge text-bg-success">Current plan</span>
</div>
<p class="text-muted small mb-2">Billing details are still being processed.</p>
<p class="small text-danger mb-2">Entitlements are suspended until the invoice is paid.</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
<li class="mb-1">&middot; 1 Plan Site</li>
</ul>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Duo Max</h4>
</div>
<p class="text-muted small mb-2">$40.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="border-start border-3 ps-3 mb-4 border-secondary-subtle">
<h3 class="fw-bold mb-3 h6 text-muted">
Prices
</h3>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Entry</h4>
</div>
<p class="text-muted small mb-2">$5.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "<price:Entry>"}'
hx-target="#checkout-error-<ladder:Prices>-<product:Entry>" hx-swap="innerHTML">Subscribe</button>
<div id="checkout-error-<ladder:Prices>-<product:Entry>" class="small text-danger mt-1"></div>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Once</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Once>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Once>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Unmapped</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Unmapped>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Unmapped>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Elsewhere</h4>
</div>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Elsewhere>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Elsewhere>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Bare</h4>
</div>
<p class="text-muted small mb-2">Included</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<span class="disabled-control d-inline-block"><button type="button" class="btn btn-secondary btn-sm w-100 mt-auto opacity-50" disabled aria-describedby="move-disabled-<ladder:Prices>-<product:Bare>">Subscribe</button></span>
<small id="move-disabled-<ladder:Prices>-<product:Bare>" class="text-muted d-block mt-1">Not available for purchase yet</small>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,396 @@
{
"Ladders": [
{
"Name": "Wiki",
"SortOrder": 1,
"Tiers": [
{
"ProductID": "<product:Free>",
"Name": "Free",
"Description": "",
"Rank": 0,
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": true,
"MoveEnabled": true,
"MoveKind": "cancel",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Downgrade",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "Plus ends with this billing period. The unpaid invoice stays due.",
"Held": false
},
{
"ProductID": "<product:Basic>",
"Name": "Basic",
"Description": "",
"Rank": 1,
"Relation": "downgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Basic>",
"Purchasable": true,
"PriceText": "$5.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Plus>",
"Name": "Plus",
"Description": "",
"Rank": 2,
"Relation": "current",
"EntitlementFeatures": [
"1 Plan Site"
],
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": true,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "payment_due",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Standard>",
"Name": "Standard",
"Description": "",
"Rank": 3,
"Relation": "upgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Standard>",
"Purchasable": true,
"PriceText": "$20.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Pro>",
"Name": "Pro",
"Description": "",
"Rank": 4,
"Relation": "upgrade",
"EntitlementFeatures": [
"10 Plan Sites"
],
"MetadataFeatures": null,
"PriceID": "<price:Pro>",
"Purchasable": true,
"PriceText": "$30.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
}
],
"Enrolled": true,
"CancelsOn": ""
},
{
"Name": "Duo",
"SortOrder": 2,
"Tiers": [
{
"ProductID": "<product:Plus>",
"Name": "Plus",
"Description": "",
"Rank": 0,
"Relation": "current",
"EntitlementFeatures": [
"1 Plan Site"
],
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": true,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "payment_due",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Duo Max>",
"Name": "Duo Max",
"Description": "",
"Rank": 1,
"Relation": "upgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Duo Max>",
"Purchasable": true,
"PriceText": "$40.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
}
],
"Enrolled": true,
"CancelsOn": ""
},
{
"Name": "Prices",
"SortOrder": 3,
"Tiers": [
{
"ProductID": "<product:Entry>",
"Name": "Entry",
"Description": "",
"Rank": 0,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Entry>",
"Purchasable": true,
"PriceText": "$5.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Once>",
"Name": "Once",
"Description": "",
"Rank": 1,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Once>",
"Purchasable": true,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Unmapped>",
"Name": "Unmapped",
"Description": "",
"Rank": 2,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Unmapped>",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Elsewhere>",
"Name": "Elsewhere",
"Description": "",
"Rank": 3,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Elsewhere>",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
},
{
"ProductID": "<product:Bare>",
"Name": "Bare",
"Description": "",
"Rank": 4,
"Relation": "available",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"NoPriceTier": true,
"MoveEnabled": false,
"MoveKind": "checkout",
"MoveLadderID": "<ladder:Prices>",
"MoveLabel": "Subscribe",
"DisabledReason": "Not available for purchase yet",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false
}
],
"Enrolled": false,
"CancelsOn": ""
}
],
"Error": "",
"Success": ""
}
@@ -28,6 +28,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "You keep your current plan until the end of this billing period.",
@@ -57,6 +58,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -88,6 +90,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -117,6 +120,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -148,6 +152,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -187,6 +192,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -216,6 +222,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -253,6 +260,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -282,6 +290,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -311,6 +320,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -340,6 +350,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
@@ -369,6 +380,7 @@
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
+26
View File
@@ -312,6 +312,32 @@ func (w *World) ConferUnprojected(o Org, p Product) string {
return id
}
// ConferUnprojectedSuspended confers p as ConferUnprojected does, then
// suspends the provision, and returns the provision id. The reconcile
// never writes this state, because it projects the subscription before it
// suspends; the state is the payment-due default a position whose
// subscription row is missing reads as. The subscription id names no row, so
// no status is written.
func (w *World) ConferUnprojectedSuspended(o Org, p Product) string {
w.TB.Helper()
var id string
w.inMaterializingTx("confer "+p.Name+" from an unprojected subscription and suspend it", func(tx *sql.Tx) error {
subID := uuid.New()
var err error
if id, err = w.conferProvision(tx, o, p, subID); err != nil {
return err
}
q := entitlements.New(tx)
if _, err := q.SyncSourceStatus(w.Ctx, entitlements.SyncSourceStatusParams{
Status: "suspended", SubscriptionID: uuid.NullUUID{UUID: subID, Valid: true},
}); err != nil {
return err
}
return entitlements.MaterializePoolEntitlements(w.Ctx, q, o.PoolID)
})
return id
}
// conferProvision confers p on o's pool from the subscription id, as the
// subscription reconcile does, and materializes the pool.
func (w *World) conferProvision(tx *sql.Tx, o Org, p Product, subscriptionID uuid.UUID) (string, error) {
+32
View File
@@ -63,3 +63,35 @@ func checkSuspend(t *testing.T, d *sql.DB, status string) {
t.Errorf("the pool's limit = %d, want 0", got)
}
}
// TestConferUnprojectedSuspendedSuspendsWithoutASubscriptionRow: the provision
// reads suspended, the pool's limit for the product's numeric key is 0, and
// no core.subscriptions row has the provision's subscription id.
func TestConferUnprojectedSuspendedSuspendsWithoutASubscriptionRow(t *testing.T) {
d := dbtest.Open(t)
if _, err := d.ExecContext(t.Context(), `INSERT INTO core.resource_keys (resource_key, display_name, unit, kind)
VALUES ($1, 'Suspend Sites', 'site', 'numeric') ON CONFLICT DO NOTHING`, suspendKey); err != nil {
t.Fatalf("register %s: %v", suspendKey, err)
}
w := world.New(t, d)
p := w.Product("Unprojected")
w.Rule(p, entitlements.RuleFields{
RuleType: "limit",
ResourceKey: sql.NullString{String: suspendKey, Valid: true},
ResourceValue: sql.NullInt64{Int64: 3, Valid: true},
StackingPolicy: sql.NullString{String: "additive", Valid: true},
})
o := w.Org("Unprojected", w.OrgType("unproj"), w.Person("Ann"))
id := w.ConferUnprojectedSuspended(o, p)
q := dbtest.On(t, d)
if got := q.String(`SELECT status FROM core.pool_provisions WHERE provision_id = $1`, id); got != "suspended" {
t.Errorf("the provision reads %q, want suspended", got)
}
if got := q.Int(`SELECT resource_limit FROM core.numeric_entitlements WHERE pool_id = $1 AND resource_key = $2`, o.PoolID, suspendKey); got != 0 {
t.Errorf("the pool's limit = %d, want 0", got)
}
if got := q.Int(`SELECT count(*) FROM core.subscriptions s JOIN core.pool_provisions pp ON pp.subscription_id = s.subscription_id WHERE pp.provision_id = $1`, id); got != 0 {
t.Errorf("%d subscription rows have the provision's subscription id, want none", got)
}
}
@@ -84,7 +84,7 @@ Reason: the endpoint and fulfillment each refuse, as the existing gates do (the
`KeepCurrentPlan` and `CancelSubscription` (`plan_change.go:424-459`, `554-618`) resolve a suspended position's subscription as they resolve an active one's and run as for an active one, with three refusals, each before it changes anything:
- **Keep plan refuses a downgrade that Stripe has already applied.** The console's schedule enters its second phase at the renewal. Stripe finalizes and charges the renewal invoice "one hour after the last webhook was successfully sent" (stripe-go `invoice.go:3172-3176`), and in that hour `customer.subscription.updated` reconciles the subscription while it is still active (`webhook_subscription.go:74-76`): that reconcile converges the landed downgrade and releases the spent schedule (`reconcile.go:105-112`, `:126-128`). A payment-due plan whose scheduled downgrade has landed and has not been converged therefore arises only when no reconcile ran in that hour. The same window exists on an active subscription; this change leaves the active path as it is, and the check applies to a suspended subscription only. `KeepCurrentPlan` reads the subscription and applies the test `findSpentSchedule` applies (`reconcile.go:891-911`): when the subscription no longer carries the price that the schedule's `scheduled_from_price_id` names, it releases nothing, supersedes nothing, and returns `ErrScheduledMoveApplied`, which `renderPlansAfterMove` renders as copy row 17. A schedule whose downgrade has not landed is released and its row superseded, as on an active subscription. Without the refusal, Keep plan would release the schedule, so the subscription would stay on the lower price, supersede the `plan_switch` row that Stripe has applied, and reconcile into the suspended branch; the card would read the higher plan again, Stripe would bill the lower one, and on recovery `reconcileItems` would move the plan with no applied row in the ledger.
- **Keep plan refuses a downgrade that Stripe has already applied.** The console's schedule enters its second phase at the renewal. Stripe finalizes and charges the renewal invoice "one hour after the last webhook was successfully sent" (stripe-go `invoice.go:3172-3176`), and in that hour `customer.subscription.updated` reconciles the subscription while it is still active (`webhook_subscription.go:74-76`): that reconcile converges the landed downgrade and releases the spent schedule (`reconcile.go:105-112`, `:126-128`). A payment-due plan whose scheduled downgrade has landed and has not been converged therefore arises only when no reconcile ran in that hour. The same window exists on an active subscription; this change leaves the active path as it is, and the check applies to a suspended subscription only. `KeepCurrentPlan` reads the subscription and refuses when either test the reconcile uses finds the downgrade landed: rule 4's `scheduleIsSpent` (the subscription no longer carries the price that the attached schedule's `scheduled_from_price_id` names) or rule 1's `downgradeLanded` (an active `period_end` `plan_switch` row whose target price the subscription carries now). The second test holds after the schedule released itself, because the console's schedule ends with `EndBehavior: "release"` and Stripe then returns the subscription with no schedule on the lower price; an `unpaid` subscription keeps cycling periods, so the state outlives the renewal. Either test finding it, Keep releases nothing, supersedes nothing, and returns `ErrScheduledMoveApplied`, which `renderPlansAfterMove` renders as copy row 17. A schedule whose downgrade has not landed is released and its row superseded, as on an active subscription. Without the refusal, Keep plan would release the schedule, so the subscription would stay on the lower price, supersede the `plan_switch` row that Stripe has applied, and reconcile into the suspended branch; the card would read the higher plan again, Stripe would bill the lower one, and on recovery `reconcileItems` would move the plan with no applied row in the ledger.
- **A posted `timing=immediate` is refused on a suspended subscription.** `CancelSubscription` returns `ErrImmediateCancelSuspended` before it releases a schedule or makes a Stripe call, and `renderPlansAfterMove` renders it as copy row 18. Stripe's immediate cancellation "stops automatic collection of all finalized invoices for the customer" (`subscription/client.go:114`), which abandons the unpaid invoice and reaches the customer's other subscriptions; the page has never offered it, and an endpoint that accepts what the page hides would let a crafted request do it.
- **A paused subscription is refused all three acts.** `SwitchPlan`, `KeepCurrentPlan` and `CancelSubscription` return `ErrSubscriptionPaused` for a position whose subscription is `paused`, before any Stripe call, rendered as copy row 8.
@@ -257,7 +257,7 @@ Reused unchanged: Checkout's refusal (`That plan is no longer available. Refresh
- **The decision** (`internal/plans/offer_test.go`, no database, over the file's ladder with Plus current): a suspended position answers none with `Suspended` for a tier above and a paid tier below; Cancel for the held rung below and for the unpriced rank-0 tier when it follows; none with `Current` for Plus; the same targets with `Suspended` false answer as today; and a paused position answers none with `Paused` for a tier above, for the held rung below and for the free rung that follows, and none with `Current` for Plus. The table's runner moves out of its inline closure into a named function that prints `Offer(<row name>) = <got>, want <want>`, so each break of the paused and suspended rules reports a text it can match.
- **The read and the proof** (`internal/entitlements`): the widened query returns a suspended position with both statuses and orders active rows first; `ExcludeSuspended` keeps every row but a suspended one; a suspended position yields a 0 numeric limit with its row kept and no granted boolean, and materializing again after the widened read returns its row changes neither.
- **Fulfillment** (`internal/fulfillment`, over a subscription the reconcile suspended from the Stripe mock's `past_due` subscription): `SwitchPlan` refuses both directions with `ErrSubscriptionSuspended` and the mock records no call after the reconcile; Cancel sends `cancel_at_period_end=true` and records the row; Keep sends `cancel_at_period_end=false` and supersedes it; Keep over a downgrade scheduled while the subscription was active, still pending when it turned `past_due`, releases the schedule and supersedes the row; `SubscriptionBacksLadder` answers true. A subscription with a downgrade scheduled that the mock's subscription already carries the lower price for: Keep returns `ErrScheduledMoveApplied`, the mock records no write, and the `plan_switch` row stays `scheduled`. Cancel with `CancelImmediate` returns `ErrImmediateCancelSuspended` and the mock records no call. A subscription the mock holds as `paused`: `SwitchPlan`, `KeepCurrentPlan` and `CancelSubscription` return `ErrSubscriptionPaused` and the mock records no call.
- **Fulfillment** (`internal/fulfillment`, over a subscription the reconcile suspended from the Stripe mock's `past_due` subscription): `SwitchPlan` refuses both directions with `ErrSubscriptionSuspended` and the mock records no call after the reconcile; Cancel sends `cancel_at_period_end=true` and records the row; Keep sends `cancel_at_period_end=false` and supersedes it; Keep over a downgrade scheduled while the subscription was active, still pending when it turned `past_due`, releases the schedule and supersedes the row; `SubscriptionBacksLadder` answers true. A subscription with a downgrade scheduled that the mock's subscription already carries the lower price for: Keep returns `ErrScheduledMoveApplied`, the mock records no write, and the `plan_switch` row stays `scheduled`. The same with the schedule already released (the mock's subscription carries no schedule): Keep returns `ErrScheduledMoveApplied` as well. Cancel with `CancelImmediate` returns `ErrImmediateCancelSuspended` and the mock records no call. A subscription the mock holds as `paused`: `SwitchPlan`, `KeepCurrentPlan` and `CancelSubscription` return `ErrSubscriptionPaused` and the mock records no call.
- **The Products page** (`internal/server`): member plans golden states for `past_due`, `unpaid`, `paused` (no control on its card), `past_due` with a scheduled cancellation, `past_due` over a held rung below, `past_due` on an organization type with no default ladder (copy row 19), `past_due` with a downgrade still pending ("moves to", Keep plan) and with one that has landed (no "moves to" line, no Keep plan, Cancel plan kept), and `past_due` on a subscription whose price is not projected (no Pay invoice); the cancel bodies of rows 19 to 22 and 24 by a table test of the prediction's body; a database test that the suspended ladder is held, draws no enabled Checkout or switch, and offers Cancel plan; Checkout 422 with no Stripe call on that ladder and a pass on another; switch and preview refused with copy row 7 or 8 and no Stripe call; a paused plan's Keep and Cancel refused with copy row 8, a Keep on a landed downgrade with copy row 17 and a posted immediate cancellation with copy row 18, each with no Stripe write.
- **Pay invoice** (`internal/server`, the Stripe mock serving `GET /v1/invoices/{id}` and `GET /v1/subscriptions/{id}`): 404 for another organization's invoice and plan; 303 to the hosted URL for an open invoice and, by invoice, for a paid one; 303 to `/billing` by plan for a paid latest invoice, for an active position with no Stripe call, for a subscription with no mapping row or an unreachable mapping with no Stripe call, and for a failed read; the by-plan read carries the expand of `latest_invoice` (the test reads it in the form the mock records the call); `Cache-Control: no-store`; no captured log record carries the URL; the card's and the detail's controls; the route tables.
- **The entitlements panel and the dashboard**: member entitlements golden states for `past_due` and `paused`; a dashboard row for an organization whose only plan grants a boolean and is suspended.
@@ -37,34 +37,35 @@ Sections 1 to 6 are one commit each, and their items are that commit's parts; se
## 3. Fulfillment and the Products page hold the suspended plan
- [ ] 3.1 `internal/fulfillment/plan_change.go` (design D5, D6): `ladderSubscription` gains `positionStatus string` and `subscriptionStatus string`, the row's `position_status` and `subscription_status`; `resolveLadderSubscription` no longer calls `ExcludeSuspended`, and its doc comment says that it resolves the subscription behind a live position, active or suspended.
- [ ] 3.2 `ErrSubscriptionSuspended`, with the text `switch: the subscription's position is suspended`, and a doc comment that says a suspended subscription is kept or cancelled and never switched, because a switch would invoice while the failed invoice is still open; `ErrSubscriptionPaused`, with the text `the subscription is paused`, whose doc comment says that a paused subscription is refused a switch, Keep and Cancel because what a cancellation does to one is unverified; `ErrScheduledMoveApplied`, with the text `keep: the scheduled move has already been applied`; and `ErrImmediateCancelSuspended`, with the text `cancel: a suspended subscription cannot be cancelled immediately`, whose doc comment says that Stripe's immediate cancellation stops automatic collection of the customer's finalized invoices. `SwitchPlan` returns `ErrSubscriptionPaused` when the resolved `subscriptionStatus` is `paused` and `ErrSubscriptionSuspended` for any other suspended position, directly after `resolveLadderSubscription` (after `plan_change.go:162-165`), before it reads the target price, the mappings or the commitment, and its doc comment says so.
- [ ] 3.3 `HasActiveSubscriptionOnLadder` becomes `SubscriptionBacksLadder`, whose doc comment says it reports whether a subscription backs the organization's position on the ladder, active or suspended, and that the preview reads it after the offer decision, which refuses a suspended position first. The caller at `internal/server/member_plan_moves.go:87` and its comment change with it.
- [ ] 3.4 `KeepCurrentPlan` and `CancelSubscription` (design D6): both return `ErrSubscriptionPaused` for a paused subscription, directly after resolving it. `KeepCurrentPlan` on a suspended subscription reads the subscription and, when it no longer carries the price that the schedule's `scheduled_from_price_id` names (the `findSpentSchedule` test, `reconcile.go:891-911`, extracted into a function that `reconcile.go` and `KeepCurrentPlan` both call), returns `ErrScheduledMoveApplied` before it releases a schedule or supersedes a row; a schedule whose downgrade has not landed is released and its row superseded, as on an active subscription, and the check applies to a suspended subscription only, the active path being left as it is. `CancelSubscription` returns `ErrImmediateCancelSuspended` for `CancelImmediate` on a suspended subscription, before it releases a schedule or makes a Stripe call. Otherwise both are unchanged; their doc comments say that they act on a suspended subscription as on an active one, that the reconcile after them keeps its provisions suspended, and name the three refusals.
- [ ] 3.5 `internal/plans/offer.go` (design D3): `Position` gains `Suspended bool` and `Paused bool` (`Paused` implies `Suspended`), with a doc comment that says what sets them; the reasons `Suspended Reason = "the position is suspended"` and `Paused Reason = "the position is paused"`; two new rules. The paused rule is rule 4, applied in `Offer` directly after rule 3 and before `heldRules`: a paused position is offered none with `Paused` for every target that rules 1 to 3 did not answer, so the current product keeps `Current`. The suspended rule is rule 15, applied in `route` directly before the switch: a suspended position's switch is `Move{Reason: Suspended}` with its relation. Today's rules 4 to 13 become 5 to 14 and the switch becomes rule 16. `Offer`'s doc comment lists the sixteen rules and says why the paused rule sits above the held rung's rule 8 and the free rung's rule 14 (it replaces their Cancel answers on a paused position) and why the suspended rule sits below them (they keep their Cancel answers on a suspended position). The comments that name rule numbers (the one at `offer.go:107`, `heldRules`, `offersNothing` and `route`) follow the renumbering.
- [ ] 3.6 `internal/server/plan_offer.go` (design D2, D7): `onLadder` sets `pos.Suspended` from the row's `position_status`; it sets `pos.Paused` only when `position_status` is `suspended` and `subscription_status` is `paused`; for a suspended position whose scheduled downgrade has a `DowngradeAt` that is not after the handler's now, it leaves the downgrade unset (`ScheduledTo` and the facts' name and date), because Stripe has already applied it; `ladderFacts` gains `Suspension`, set only when `position_status` is `suspended`: `paused` when the subscription's status is `paused` and `payment_due` for every other status; `positions` no longer calls `ExcludeSuspended`.
- [ ] 3.7 `switchOffer` (design D5): after the `NotOnLadder` case and before `!pos.Subscribed`, a suspended position returns `fulfillment.ErrSubscriptionPaused` when `pos.Paused` is set (not the facts, which `switchOffer` discards, `plan_offer.go:307`), and `fulfillment.ErrSubscriptionSuspended` otherwise, logging copy row 16 at Info with `ladder_id` and `subscription_status`; its doc comment lists the order, with the member gate and the malformed or unknown price id answering before it. The server's own `errPlanPaused` does not exist. `renderPlansAfterMove` renders `ErrSubscriptionSuspended` as copy row 7, `ErrSubscriptionPaused` as copy row 8, `ErrScheduledMoveApplied` as copy row 17 and `ErrImmediateCancelSuspended` as copy row 18.
- [ ] 3.8 `internal/server/member_plans.go` (design D6, D7): `TierViewModel` gains `Suspension string`, which `setCurrent` copies from the facts before its early return for an empty price text, so that a card whose price is not projected carries it too, and `setCurrent` sets neither `CanKeep` nor `CanCancel` when it reads `paused`; `setCancelBodies`, when the ladder's current tier reads `payment_due`, builds the body of every control on the ladder from copy rows 19 to 22 and 24 in place of "You keep {current} until {date}" (row 24 when every covered ladder keeps its plan, row 19 when nothing follows on any covered ladder, row 20 when only the plan's own ladder is covered and another plan follows, row 21 for every other prediction with the existing clause words after "then you", row 22 when there is no prediction or no date) and appends copy row 6 after a space; a paused ladder gets no body; `LadderViewModel.CancelBadge` (`internal/server/anatomy.go:1490-1492`) leaves `Title` empty when the ladder's current tier carries a `Suspension`.
- [ ] 3.9 `internal/embeds/templates/partials/member_plans.html`: on the current card, when `Suspension` is set and neither `MovesTo` nor `EndsOn` is, the price line reads copy row 1 or 2 in place of the renewal line; under the price line, when `Suspension` is set, the fact line (copy row 3 or 4) renders as `<p class="small text-danger mb-2">`; a paused card draws no control (the view model sets none). The template comment above the price lines says so.
- [ ] 3.10 Docs (design D15): `docs/stripe.md`, "Plan changes", a bullet for a plan whose payment is due (it stays on its card with its state; no switch; Keep plan and Cancel plan at the period end work, a posted immediate cancellation and a Keep plan over a downgrade Stripe already applied are refused; the unpaid invoice stays due; a paused plan shows its state and is refused every act); `docs/models/payments-billing.md`, "Status classification", says that the member surfaces show a suspended plan as the current plan with its state; `docs/models/plan-ladders-transitions.md`, the "Member position" row, says that the Products page, Checkout and the switch hold a suspended position as the current plan.
- [ ] 3.11 Fulfillment tests (`internal/fulfillment`, Stripe-mocked database tests over the reconcile fixture of `plan_switch_timing_test.go`): a helper builds `onTier2`'s state, installs `stripeLike(mock)`, sets the mock's subscription to `Status: past_due` (or `paused` where a row says so), reconciles again, and checks that the subscription row reads that status and its provisions `suspended`, the pair the reconcile writes. Each test's failure texts are written here because the breaks match them.
- [x] 3.1 `internal/fulfillment/plan_change.go` (design D5, D6): `ladderSubscription` gains `positionStatus string` and `subscriptionStatus string`, the row's `position_status` and `subscription_status`; `resolveLadderSubscription` no longer calls `ExcludeSuspended`, and its doc comment says that it resolves the subscription behind a live position, active or suspended.
- [x] 3.2 `ErrSubscriptionSuspended`, with the text `switch: the subscription's position is suspended`, and a doc comment that says a suspended subscription is kept or cancelled and never switched, because a switch would invoice while the failed invoice is still open; `ErrSubscriptionPaused`, with the text `the subscription is paused`, whose doc comment says that a paused subscription is refused a switch, Keep and Cancel because what a cancellation does to one is unverified; `ErrScheduledMoveApplied`, with the text `keep: the scheduled move has already been applied`; and `ErrImmediateCancelSuspended`, with the text `cancel: a suspended subscription cannot be cancelled immediately`, whose doc comment says that Stripe's immediate cancellation stops automatic collection of the customer's finalized invoices. `SwitchPlan` returns `ErrSubscriptionPaused` when the resolved `subscriptionStatus` is `paused` and `ErrSubscriptionSuspended` for any other suspended position, directly after `resolveLadderSubscription` (after `plan_change.go:162-165`), before it reads the target price, the mappings or the commitment, and its doc comment says so.
- [x] 3.3 `HasActiveSubscriptionOnLadder` becomes `SubscriptionBacksLadder`, whose doc comment says it reports whether a subscription backs the organization's position on the ladder, active or suspended, and that the preview reads it after the offer decision, which refuses a suspended position first. The caller at `internal/server/member_plan_moves.go:87` and its comment change with it.
- [x] 3.4 `KeepCurrentPlan` and `CancelSubscription` (design D6): both return `ErrSubscriptionPaused` for a paused subscription, directly after resolving it. `KeepCurrentPlan` on a suspended subscription reads the subscription and, when it no longer carries the price that the schedule's `scheduled_from_price_id` names (rule 4's test, `scheduleIsSpent`), or when an active `period_end` `plan_switch` row's target price is one the subscription carries now (rule 1's test, `downgradeLanded`, which holds after the schedule released itself and the subscription carries none), returns `ErrScheduledMoveApplied`; both tests live in `reconcile.go` and the reconcile and `KeepCurrentPlan` both call them, before it releases a schedule or supersedes a row; a schedule whose downgrade has not landed is released and its row superseded, as on an active subscription, and the check applies to a suspended subscription only, the active path being left as it is. `CancelSubscription` returns `ErrImmediateCancelSuspended` for `CancelImmediate` on a suspended subscription, before it releases a schedule or makes a Stripe call. Otherwise both are unchanged; their doc comments say that they act on a suspended subscription as on an active one, that the reconcile after them keeps its provisions suspended, and name the three refusals.
- [x] 3.5 `internal/plans/offer.go` (design D3): `Position` gains `Suspended bool` and `Paused bool` (`Paused` implies `Suspended`), with a doc comment that says what sets them; the reasons `Suspended Reason = "the position is suspended"` and `Paused Reason = "the position is paused"`; two new rules. The paused rule is rule 4, applied in `Offer` directly after rule 3 and before `heldRules`: a paused position is offered none with `Paused` for every target that rules 1 to 3 did not answer, so the current product keeps `Current`. The suspended rule is rule 15, applied in `route` directly before the switch: a suspended position's switch is `Move{Reason: Suspended}` with its relation. Today's rules 4 to 13 become 5 to 14 and the switch becomes rule 16. `Offer`'s doc comment lists the sixteen rules and says why the paused rule sits above the held rung's rule 8 and the free rung's rule 14 (it replaces their Cancel answers on a paused position) and why the suspended rule sits below them (they keep their Cancel answers on a suspended position). The comments that name rule numbers (the one at `offer.go:107`, `heldRules`, `offersNothing` and `route`) follow the renumbering.
- [x] 3.6 `internal/server/plan_offer.go` (design D2, D7): `onLadder` sets `pos.Suspended` from the row's `position_status`; it sets `pos.Paused` only when `position_status` is `suspended` and `subscription_status` is `paused`; for a suspended position whose scheduled downgrade has a `DowngradeAt` that is not after the handler's now, it leaves the downgrade unset (`ScheduledTo` and the facts' name and date), because Stripe has already applied it; `ladderFacts` gains `Suspension`, set only when `position_status` is `suspended`: `paused` when the subscription's status is `paused` and `payment_due` for every other status; `positions` no longer calls `ExcludeSuspended`.
- [x] 3.7 `switchOffer` (design D5): after the `NotOnLadder` case and before `!pos.Subscribed`, a suspended position returns `fulfillment.ErrSubscriptionPaused` when `pos.Paused` is set (not the facts, which `switchOffer` discards, `plan_offer.go:307`), and `fulfillment.ErrSubscriptionSuspended` otherwise, logging copy row 16 at Info with `ladder_id` and `subscription_status`; its doc comment lists the order, with the member gate and the malformed or unknown price id answering before it. The server's own `errPlanPaused` does not exist. `renderPlansAfterMove` renders `ErrSubscriptionSuspended` as copy row 7, `ErrSubscriptionPaused` as copy row 8, `ErrScheduledMoveApplied` as copy row 17 and `ErrImmediateCancelSuspended` as copy row 18.
- [x] 3.8 `internal/server/member_plans.go` (design D6, D7): `TierViewModel` gains `Suspension string`, which `setCurrent` copies from the facts before its early return for an empty price text, so that a card whose price is not projected carries it too, and `setCurrent` sets neither `CanKeep` nor `CanCancel` when it reads `paused`; `setCancelBodies`, when the ladder's current tier reads `payment_due`, builds the body of every control on the ladder from copy rows 19 to 22 and 24 in place of "You keep {current} until {date}" (row 24 when every covered ladder keeps its plan, row 19 when nothing follows on any covered ladder, row 20 when only the plan's own ladder is covered and another plan follows, row 21 for every other prediction with the existing clause words after "then you", row 22 when there is no prediction or no date) and appends copy row 6 after a space; a paused ladder gets no body; `LadderViewModel.CancelBadge` (`internal/server/anatomy.go:1490-1492`) leaves `Title` empty when the ladder's current tier carries a `Suspension`.
- [x] 3.9 `internal/embeds/templates/partials/member_plans.html`: on the current card, when `Suspension` is set and neither `MovesTo` nor `EndsOn` is, the price line reads copy row 1 or 2 in place of the renewal line; under the price line, when `Suspension` is set, the fact line (copy row 3 or 4) renders as `<p class="small text-danger mb-2">`; a paused card draws no control (the view model sets none). The template comment above the price lines says so.
- [x] 3.10 Docs (design D15): `docs/stripe.md`, "Plan changes", a bullet for a plan whose payment is due (it stays on its card with its state; no switch; Keep plan and Cancel plan at the period end work, a posted immediate cancellation and a Keep plan over a downgrade Stripe already applied are refused; the unpaid invoice stays due; a paused plan shows its state and is refused every act); `docs/models/payments-billing.md`, "Status classification", says that the member surfaces show a suspended plan as the current plan with its state; `docs/models/plan-ladders-transitions.md`, the "Member position" row, says that the Products page, Checkout and the switch hold a suspended position as the current plan.
- [x] 3.11 Fulfillment tests (`internal/fulfillment`, Stripe-mocked database tests over the reconcile fixture of `plan_switch_timing_test.go`): a helper builds `onTier2`'s state, installs `stripeLike(mock)`, sets the mock's subscription to `Status: past_due` (or `paused` where a row says so), reconciles again, and checks that the subscription row reads that status and its provisions `suspended`, the pair the reconcile writes. Each test's failure texts are written here because the breaks match them.
- `TestSwitchRefusesASuspendedSubscription`, one row per direction through a named function (an upgrade to a third tier `addTier` adds; a downgrade to tier 1): `SwitchPlan` returns an error that `errors.Is` `ErrSubscriptionSuspended`, the mock records no call during it, and no `subscription_scheduled_changes` row is written. Failures read `SwitchPlan on a suspended subscription returned <err>, want ErrSubscriptionSuspended`, `the switch called Stripe: <calls>` and `the switch wrote a scheduled change`.
- `TestCancelASuspendedSubscriptionAtPeriodEnd`: `CancelSubscription` at the period end returns nil, the mock received `POST /v1/subscriptions/{id}` with `cancel_at_period_end=true`, one scheduled cancellation row reads `scheduled`, and the provisions still read `suspended`. A failure reads `CancelSubscription on a suspended subscription returned <err>, want nil`.
- `TestKeepASuspendedSubscription`: after that cancellation, `KeepCurrentPlan` returns nil, the mock received `cancel_at_period_end=false`, and the cancellation row reads `superseded`. A failure reads `KeepCurrentPlan on a suspended subscription returned <err>, want nil`.
- `TestKeepReleasesAPendingDowngradeOnASuspendedSubscription`: `onTier2`, a downgrade to tier 1 scheduled while the subscription is active, then the mock's subscription `past_due` and a reconcile; `KeepCurrentPlan` returns nil, the mock received the release of the schedule, the `plan_switch` row reads `superseded`, and the provisions still read `suspended`. Failures read `KeepCurrentPlan on a pending downgrade of a suspended subscription returned <err>, want nil`, `the keep sent no release of the schedule`, `the plan_switch row reads <status>, want superseded` and `the provisions read <status>, want suspended`.
- `TestKeepRefusesADowngradeThatLanded`: a suspended subscription with a downgrade scheduled whose schedule carries `scheduled_from_price_id` and whose mock subscription now carries the lower tier's price: `KeepCurrentPlan` returns an error that `errors.Is` `ErrScheduledMoveApplied`, the mock records no write (the read of the subscription is the only call), and the `plan_switch` row still reads `scheduled`. A failure reads `KeepCurrentPlan on a landed downgrade returned <err>, want ErrScheduledMoveApplied`.
- `TestKeepRefusesADowngradeThatLandedAfterTheScheduleReleased`: the fixture of the previous test with the mock subscription's schedule nil: `KeepCurrentPlan` returns an error that `errors.Is` `ErrScheduledMoveApplied`, the mock records no write, and the `plan_switch` row still reads `scheduled`. A failure reads `KeepCurrentPlan on a landed downgrade returned <err>, want ErrScheduledMoveApplied`.
- `TestCancelImmediatelyRefusesASuspendedSubscription`: `CancelSubscription` with `CancelImmediate` returns an error that `errors.Is` `ErrImmediateCancelSuspended`, the mock records no call and no row is written. Failures read `CancelSubscription(immediate) on a suspended subscription returned <err>, want ErrImmediateCancelSuspended` and `the cancellation called Stripe: <calls>`.
- `TestPausedSubscriptionRefusesEveryAct`, one row per act through a named function (`SwitchPlan`, `KeepCurrentPlan`, `CancelSubscription`), over a subscription the mock holds as `paused`: each returns an error that `errors.Is` `ErrSubscriptionPaused` and the mock records no call. A failure reads `<act> on a paused subscription returned <err>, want ErrSubscriptionPaused`.
- `TestSubscriptionBacksASuspendedLadder`: true for the suspended fixture's organization and ladder. A failure reads `SubscriptionBacksLadder = false for a suspended position`.
- [ ] 3.12 Page tests. `internal/plans/offer_test.go`: the table's runner moves out of its inline closure (`offer_test.go:183-190`, which prints `Offer = %+v, want %+v`) into a named function that prints `Offer(%s) = %+v, want %+v` with the row's name, so that the rule breaks below can match their text (an existing test changes, and the commit message names it). The rows run over the file's ladder (free, basic, standard, plus, pro) with Plus current and subscribed, through its `held` helper (Standard held) and its `below("free", …)` helper (a prediction that puts Free on the ladder). Suspended: "suspended, a tier above" (Pro) and "suspended, a priced tier below" (Basic) answer none with `Suspended`; "suspended, the free rung that follows" (Free) answers Cancel, enabled; "suspended, the current tier" (Plus) answers none with `Current`; "suspended, the held rung below" (Standard) answers Cancel, enabled, held; "not suspended, a tier above" and "not suspended, a priced tier below" answer a switch, as the decision does today. Paused: "paused, a tier above" (Pro), "paused, the held rung below" (Standard) and "paused, the free rung that follows" (Free) each answer none with `Paused`, and "paused, the current tier" (Plus) answers none with `Current`.
- [x] 3.12 Page tests. `internal/plans/offer_test.go`: the table's runner moves out of its inline closure (`offer_test.go:183-190`, which prints `Offer = %+v, want %+v`) into a named function that prints `Offer(%s) = %+v, want %+v` with the row's name, so that the rule breaks below can match their text (an existing test changes, and the commit message names it). The rows run over the file's ladder (free, basic, standard, plus, pro) with Plus current and subscribed, through its `held` helper (Standard held) and its `below("free", …)` helper (a prediction that puts Free on the ladder). Suspended: "suspended, a tier above" (Pro) and "suspended, a priced tier below" (Basic) answer none with `Suspended`; "suspended, the free rung that follows" (Free) answers Cancel, enabled; "suspended, the current tier" (Plus) answers none with `Current`; "suspended, the held rung below" (Standard) answers Cancel, enabled, held; "not suspended, a tier above" and "not suspended, a priced tier below" answer a switch, as the decision does today. Paused: "paused, a tier above" (Pro), "paused, the held rung below" (Standard) and "paused, the free rung that follows" (Free) each answer none with `Paused`, and "paused, the current tier" (Plus) answers none with `Current`.
- `TestCancelPredictionBodyPaymentDue` (`grant_resumption_render_test.go`, beside `TestCancelPredictionBody`, over `predictionOf`, printing `<case>:\n got %q\nwant %q` as its sibling does): "nothing follows" reads copy row 19; "only the plan's own ladder is covered" reads row 20; "several ladders" reads row 21 with the clause words; "every covered ladder keeps its plan" reads row 24.
- `TestMemberPlansGolden`: `paid-pro-past-due` and `paid-pro-past-due-held-plus-below` change on purpose (the ladder held, Pro's card with copy rows 1 and 3 and Cancel plan, no Upgrade; the fixture's organization type has Wiki as its default ladder with Free as the floor, so Free follows in the first and the second covers two ladders: the first's Cancel plan body is copy row 20 followed by copy row 6, "Pro ends on Jan 15, 2099, then you move to Free. The unpaid invoice stays due."; the second shows Plus's "Granted" with its Downgrade, and the bodies of Pro's Cancel plan and Plus's Downgrade are copy row 21 followed by copy row 6, "Pro ends on Jan 15, 2099, then you move to Plus on Wiki and Duo. The unpaid invoice stays due."). New states: `paid-pro-unpaid` (copy rows 1 and 3, the body of copy row 20 as the first state); `paid-pro-paused` (copy rows 2 and 4, and no Pay invoice, no Keep plan and no Cancel plan); `paid-pro-past-due-no-default` (an organization type with no default ladder, Pro subscribed and suspended `past_due`: Cancel plan whose body is copy row 19 followed by copy row 6, "Pro ends on Jan 15, 2099. The unpaid invoice stays due."); `paid-pro-past-due-cancellation-scheduled` (`Subscribe`, `CancelAtPeriodEnd`, `ScheduleCancellation`, then `Suspend`: "ends" with the fact line, Keep plan alone, and the heading's badge without a `title`); `paid-pro-past-due-downgrade-scheduled` (`ScheduleDowngrade` at the 2099 period end, then `Suspend`: "moves to Standard on Jan 15, 2099", the fact line, Keep plan and Cancel plan); `paid-pro-past-due-downgrade-landed` (`Subscribe`, a downgrade to Plus scheduled with its date in the past, then `Suspend`: the price line of copy row 1, the fact line, no "moves to" line, no Keep plan, and Cancel plan whose body is copy row 20 followed by copy row 6); and `unprojected-subscription-past-due` (as `unprojected-subscription`, then suspended: the card without a price, and Cancel plan whose body is copy row 22 followed by copy row 6). Every JSON golden under `internal/server/testdata/member_plans/` gains `"Suspension"` on every tier: the thirteen existing states (`granted-plus`, `nothing-held`, `paid-basic-held-standard-above`, `paid-basic-plus-on-two-ladders`, `paid-plus-on-default-ladder`, `paid-plus-period-unknown`, `paid-pro-block-commitment`, `paid-pro-cancellation-flag-and-row`, `paid-pro-cancellation-row`, `paid-pro-downgrade-scheduled`, `paid-pro-held-plus-below`, `retired-current-rung`, `unprojected-subscription`) with `""` and no other change, which the commit message says.
- `TestPlansHoldASuspendedPlan` (`member_plans_offer_db_test.go`): through `buildPlansData`, Pro's tier is current with `Suspension` `payment_due` and `CanCancel`, and no tier of the ladder carries `MoveKind` `checkout` or `switch` (a failure names the tier: `tier <name> offers a switch on a suspended plan`, or `offers Checkout`); for a paused subscription, Pro's tier carries neither `CanKeep` nor `CanCancel` and no tier carries `MoveKind` `cancel` (`tier <name> offers a move on a paused plan`).
- `TestCheckoutRefusesALadderWhosePlanIsSuspended` (`billing_checkout_offer_db_test.go`), one row per target through a named function, the organization built through the kit and suspended `past_due`: a tier above and a tier below answer 422 with the plan sentence and the mock records no call (a failure reads `Checkout of a tier on a suspended plan's ladder was not refused`); a tier of a second ladder the organization holds nothing on passes; a product that is a tier of both ladders answers 422.
- `TestSwitchRefusesASuspendedPlan` (`member_switch_offer_db_test.go`), built on `seedOfferMember` (the mock plus the reconcile, because the kit maps no subscription item and `resolveLadderSubscription` would fail at `plan_change.go:762-765` before any refusal), the subscription suspended through the mock's status and the requests made through `ask` and checked through `refused`, one row per request through a named function: a switch up, a switch down, a switch to the current tier, and the preview of the switch up each render the plans partial with copy row 7 as its error, and the mock records no call; the same switch up on a `paused` subscription renders copy row 8; the refusal's captured log record is copy row 16 with `ladder_id` and `subscription_status`. A sentence that is missing fails as the file's helper prints it, `want "<sentence>" in:` followed by the body, where an apostrophe is `&#39;`.
- `TestPlanActsRefuseWhatFulfillmentRefuses` (the same file, built the same way), one row per request through a named function: Keep plan and Cancel plan on a paused plan render copy row 8; Keep plan on a payment-due plan whose downgrade has landed renders copy row 17; a posted `timing=immediate` on a payment-due plan renders copy row 18; each renders the plans partial with the sentence as its error and the mock records no write.
- [ ] 3.13 `make screens`: no changed screen expected (the preamble's reason).
- [ ] 3.14 Breaks:
- [x] 3.13 `make screens`: no changed screen expected (the preamble's reason).
- [x] 3.14 Breaks:
- `resolveLadderSubscription` keeps `ExcludeSuspended` (section 2's unseen break): caught by `TestCancelASuspendedSubscriptionAtPeriodEnd`, `want_new` `CancelSubscription on a suspended subscription returned no active paid subscription on this ladder, want nil`, and by `TestSubscriptionBacksASuspendedLadder`, `want_new` `SubscriptionBacksLadder = false for a suspended position`;
- `SwitchPlan` without its refusal: caught by `TestSwitchRefusesASuspendedSubscription`, `want_new` `SwitchPlan on a suspended subscription returned <nil>, want ErrSubscriptionSuspended`;
- the refusal moved into `downgradeAtRenewal`, after its first Stripe call: caught by the same test's downgrade row, `want_new` `the switch called Stripe:`;
@@ -72,6 +73,7 @@ Sections 1 to 6 are one commit each, and their items are that commit's parts; se
- the suspended refusal placed in `resolveLadderSubscription` instead of `SwitchPlan`: caught by `TestCancelASuspendedSubscriptionAtPeriodEnd`, `want_new` `CancelSubscription on a suspended subscription returned switch: the subscription's position is suspended, want nil`;
- `KeepCurrentPlan` without its landed-downgrade check: caught by `TestKeepRefusesADowngradeThatLanded`, `want_new` `KeepCurrentPlan on a landed downgrade returned <nil>, want ErrScheduledMoveApplied`;
- the landed check without its price comparison (it returns `ErrScheduledMoveApplied` for every suspended subscription with a scheduled row): caught by `TestKeepReleasesAPendingDowngradeOnASuspendedSubscription`, `want_new` `KeepCurrentPlan on a pending downgrade of a suspended subscription returned keep: the scheduled move has already been applied, want nil`;
- `KeepCurrentPlan` without rule 1's `downgradeLanded` test (the call removed from the landed check): caught by `TestKeepRefusesADowngradeThatLandedAfterTheScheduleReleased`, `want_new` `KeepCurrentPlan on a landed downgrade returned <nil>, want ErrScheduledMoveApplied`;
- the immediate branch without the check: caught by `TestCancelImmediatelyRefusesASuspendedSubscription`, `want_new` `CancelSubscription(immediate) on a suspended subscription returned <nil>, want ErrImmediateCancelSuspended`;
- the immediate check placed after the schedule release: caught by the same test, `want_new` `the cancellation called Stripe:`;
- `SwitchPlan`, `KeepCurrentPlan` or `CancelSubscription` without the paused refusal (three records): caught by `TestPausedSubscriptionRefusesEveryAct`'s row for that act, `want_new` `on a paused subscription returned`;