A grant over a paid tier uses up time the member paid for, or gives part of a period away, depending on where the subscription's renewal date falls #280

Open
opened 2026-10-27 16:26:31 +00:00 by cgalo5758 · 0 comments
Owner

What a person cannot do today

Some terms first. A plan ladder is a ranked sequence of tiers, and an organization holds one tier per ladder at a time. A grant is an operator's record that gives an organization a tier without payment. When an operator grants a tier on a ladder where the organization's own subscription holds a tier, the grant takes the ladder and the subscription delivers nothing there until the grant ends. As decided on #269, the subscription's payment collection is paused at Stripe for that time (pause_collection with behavior=void): each renewal invoice Stripe creates while the grant holds is voided, and collection resumes when the grant ends.

The pause moves no money at the grant's two edges. A member billed on the 1st who is granted a tier on the 10th has already paid for the whole period, and the paid tier delivers nothing from the 10th. When a grant ends on the 20th of a later period, that period's renewal was voided, so the paid tier delivers from the 20th to the period's end unpaid. The two partial periods do not offset: with a 30-day period, a grant from day 10 to day 20 of one period costs the member 10 paid days; a grant from day 25 of one period to day 5 of a period two later gives the member 20 days free; on an annual price, a 7-day grant that starts 4 days before renewal gives the member almost a year free. Which way it falls is decided by where the renewal date happens to sit, which neither the member nor the operator chose, and a pause or resume call that reaches Stripe late, after a renewal was charged or voided, shifts a whole period one way or the other with nothing on any page saying so. Only a grant of whole periods comes out exact.

So a member cannot rely on a grant leaving the time they paid for intact, and an operator cannot give a grant of any length without possibly giving away up to a whole period, a year on an annual price.

What they should be able to do

A subscription pays only for the time in which it delivers its own tier. A grant does not use up the time the member paid for: when the grant ends, that time is delivered after it, and the next charge is a full period at the subscription's price once that time runs out. The same rule holds on every deployment, with no setting.

The proposed form gives the time back as time, not money. In the update that clears the pause when the grant ends, the console also moves the subscription's next billing date to N = t_e + (P - t_s), where t_s is when the grant took the ladder, t_e is when the subscription delivers again, and P is the end of the member's paid coverage at t_s; Stripe documents moving an existing subscription's billing date with trial_end and proration_behavior=none, which makes no invoice until that date. When N falls within a few days of the current period's end, the console only clears the pause. N is computed from the periods the member actually paid for (their invoices' line periods, net of any refund or credit note) and from the grant's interval in the transitions ledger, not from what Stripe voided, so a late pause or resume call still comes out exact. A grant of whole periods leaves the renewal date where it was; any other grant moves it by the difference.

Money instead of time (a credit note and refund at the grant's start, an invoice item at its end) is the fallback, only if the time form fails the checks below: it needs three more permissions on the console's restricted Stripe key (credit notes, invoice items and refunds), so a deployment without them would quietly keep today's behaviour, and it issues refunds and credit notes the operator must reconcile.

Costs of the time form, accepted unless the checks show otherwise:

  • Stripe labels the stretch before the moved date a trial: the Dashboard shows the subscription as trialing, and with customer emails on, Stripe sends its trial-ending reminder before the next charge. The console already treats trialing as delivering (internal/fulfillment/reconcile.go), and the operator Overview counts such subscriptions in its "trialing" caption (internal/server/operator_overview.go), whose wording then needs a decision.
  • The renewal date moves after a grant that is not a whole number of periods, and the member's card shows the new date.
  • A member who cancels while a grant still holds forfeits the paid remainder, as today.
  • A switch of the paused subscription while the grant holds turns the member's unused paid value into time on the target tier, at the ratio of the two prices, and charges the target's full price when that time runs out, instead of charging a prorated difference at once; the switch's preview shows the date and amount of that first charge.

Why it matters

The console is meant for any deployment and any grant: short comps, sponsorships of a year, open-ended grants. Today a grant's cost to the member, or to the operator, depends on an accident of dates, and on annual prices the accident is large.

Where

The act that ends a grant's hold and the update that clears the pause (#269, #270); the switch (internal/fulfillment/plan_change.go); the subscription reconcile (internal/fulfillment/reconcile.go); the grant's interval (core.pool_provision_transitions); docs/stripe.md; openspec/specs/stripe-subscription-management/spec.md. A service period is read from invoice line periods, never from core.invoices.period_start, which on a subscription invoice looks back one period.

Done when

Before the change is specified, the time form has had one adversarial review, and these have been checked in a Stripe sandbox at the console's pinned API version:

  • one update on an active, paid subscription mid-period that clears the pause and sets the moved date with no proration makes no invoice and no credit, and charges a full period at the moved date;
  • the same after a voided renewal, with the moved date before the current period's end;
  • the moved date on a subscription whose billing anchor is more than two years old (the SDK caps trial_end at two years from it);
  • a cancellation at period end followed by a moved date: does the subscription end at the moved date;
  • an upgrade during the moved stretch: is anything charged before the moved date;
  • a price change, the moved date and the pause clear in one update;
  • the trial-ending email and customer.subscription.trial_will_end with customer emails on, with the email's wording recorded;
  • what happens at the moved date when the customer has no payment method;
  • the same update on a subscription made in the Dashboard in flexible billing mode, and on one a downgrade schedule manages.

Then: every grant that ends after the change ships leaves the member the paid time it covered, delivered after the grant, with the next charge at the moved date; a whole-period grant leaves the renewal date unchanged; the console records, for each ended grant, its interval, the paid coverage used, the moved date and the date Stripe confirmed; docs/stripe.md states the rule; and tests drive a short grant, a grant across a renewal, a whole-period grant, an annual price, a late pause call, and a switch during the grant.

### What a person cannot do today Some terms first. A plan ladder is a ranked sequence of tiers, and an organization holds one tier per ladder at a time. A grant is an operator's record that gives an organization a tier without payment. When an operator grants a tier on a ladder where the organization's own subscription holds a tier, the grant takes the ladder and the subscription delivers nothing there until the grant ends. As decided on #269, the subscription's payment collection is paused at Stripe for that time (`pause_collection` with `behavior=void`): each renewal invoice Stripe creates while the grant holds is voided, and collection resumes when the grant ends. The pause moves no money at the grant's two edges. A member billed on the 1st who is granted a tier on the 10th has already paid for the whole period, and the paid tier delivers nothing from the 10th. When a grant ends on the 20th of a later period, that period's renewal was voided, so the paid tier delivers from the 20th to the period's end unpaid. The two partial periods do not offset: with a 30-day period, a grant from day 10 to day 20 of one period costs the member 10 paid days; a grant from day 25 of one period to day 5 of a period two later gives the member 20 days free; on an annual price, a 7-day grant that starts 4 days before renewal gives the member almost a year free. Which way it falls is decided by where the renewal date happens to sit, which neither the member nor the operator chose, and a pause or resume call that reaches Stripe late, after a renewal was charged or voided, shifts a whole period one way or the other with nothing on any page saying so. Only a grant of whole periods comes out exact. So a member cannot rely on a grant leaving the time they paid for intact, and an operator cannot give a grant of any length without possibly giving away up to a whole period, a year on an annual price. ### What they should be able to do A subscription pays only for the time in which it delivers its own tier. A grant does not use up the time the member paid for: when the grant ends, that time is delivered after it, and the next charge is a full period at the subscription's price once that time runs out. The same rule holds on every deployment, with no setting. The proposed form gives the time back as time, not money. In the update that clears the pause when the grant ends, the console also moves the subscription's next billing date to `N = t_e + (P - t_s)`, where `t_s` is when the grant took the ladder, `t_e` is when the subscription delivers again, and `P` is the end of the member's paid coverage at `t_s`; Stripe documents moving an existing subscription's billing date with `trial_end` and `proration_behavior=none`, which makes no invoice until that date. When `N` falls within a few days of the current period's end, the console only clears the pause. `N` is computed from the periods the member actually paid for (their invoices' line periods, net of any refund or credit note) and from the grant's interval in the transitions ledger, not from what Stripe voided, so a late pause or resume call still comes out exact. A grant of whole periods leaves the renewal date where it was; any other grant moves it by the difference. Money instead of time (a credit note and refund at the grant's start, an invoice item at its end) is the fallback, only if the time form fails the checks below: it needs three more permissions on the console's restricted Stripe key (credit notes, invoice items and refunds), so a deployment without them would quietly keep today's behaviour, and it issues refunds and credit notes the operator must reconcile. Costs of the time form, accepted unless the checks show otherwise: - Stripe labels the stretch before the moved date a trial: the Dashboard shows the subscription as `trialing`, and with customer emails on, Stripe sends its trial-ending reminder before the next charge. The console already treats `trialing` as delivering (`internal/fulfillment/reconcile.go`), and the operator Overview counts such subscriptions in its "trialing" caption (`internal/server/operator_overview.go`), whose wording then needs a decision. - The renewal date moves after a grant that is not a whole number of periods, and the member's card shows the new date. - A member who cancels while a grant still holds forfeits the paid remainder, as today. - A switch of the paused subscription while the grant holds turns the member's unused paid value into time on the target tier, at the ratio of the two prices, and charges the target's full price when that time runs out, instead of charging a prorated difference at once; the switch's preview shows the date and amount of that first charge. ### Why it matters The console is meant for any deployment and any grant: short comps, sponsorships of a year, open-ended grants. Today a grant's cost to the member, or to the operator, depends on an accident of dates, and on annual prices the accident is large. ### Where The act that ends a grant's hold and the update that clears the pause (#269, #270); the switch (`internal/fulfillment/plan_change.go`); the subscription reconcile (`internal/fulfillment/reconcile.go`); the grant's interval (`core.pool_provision_transitions`); `docs/stripe.md`; `openspec/specs/stripe-subscription-management/spec.md`. A service period is read from invoice line periods, never from `core.invoices.period_start`, which on a subscription invoice looks back one period. ### Done when Before the change is specified, the time form has had one adversarial review, and these have been checked in a Stripe sandbox at the console's pinned API version: - one update on an active, paid subscription mid-period that clears the pause and sets the moved date with no proration makes no invoice and no credit, and charges a full period at the moved date; - the same after a voided renewal, with the moved date before the current period's end; - the moved date on a subscription whose billing anchor is more than two years old (the SDK caps `trial_end` at two years from it); - a cancellation at period end followed by a moved date: does the subscription end at the moved date; - an upgrade during the moved stretch: is anything charged before the moved date; - a price change, the moved date and the pause clear in one update; - the trial-ending email and `customer.subscription.trial_will_end` with customer emails on, with the email's wording recorded; - what happens at the moved date when the customer has no payment method; - the same update on a subscription made in the Dashboard in flexible billing mode, and on one a downgrade schedule manages. Then: every grant that ends after the change ships leaves the member the paid time it covered, delivered after the grant, with the next charge at the moved date; a whole-period grant leaves the renewal date unchanged; the console records, for each ended grant, its interval, the paid coverage used, the moved date and the date Stripe confirmed; `docs/stripe.md` states the rule; and tests drive a short grant, a grant across a renewal, a whole-period grant, an annual price, a late pause call, and a switch during the grant.
cgalo5758 added the
kind
enhancement
area/entitlementsarea/billing
labels 2026-10-27 16:26:31 +00:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: wiki-cafe/member-console#280