Skip to content

Subscriptions & proration

Subscriptions & proration

The module hosts two product shapes (ADR-0015): the rolling Subscription below, and the fixed-term TermSubscription for registrar-style products (jump).

A Subscription pins a plan/price version, an org, and a BillingPeriod. SubscriptionManager is the pure state machine over it; SubscriptionLifecycle wires lifecycle transitions to side effects such as wallet forfeiture.

Lifecycle

$sub = $manager->create($id, $org, $productId, $priceId, $period);
$sub = $manager->cancelAtPeriodEnd($sub);   // → NonRenewing; capacity retained until period end
$sub = $manager->resume($sub);              // NonRenewing → Active (clear the pending cancel)
$sub = $manager->cancelNow($sub);           // immediate → Canceled
$sub = $manager->scheduleChange($sub, $newPriceId, $effectiveAt); // staged, mutable
$sub = $manager->clearScheduledChange($sub);
$sub = $manager->renew($sub, $nextPeriod);  // applies a due scheduled change, or enacts a due cancel

A scheduled change (ScheduledChange) is staged and remains mutable / cancelable until its effective date — a downgrade at period end moves no money now and keeps paid capacity until then. renew applies a due change or, if the subscription is NonRenewing, transitions it to Canceled.

State machine

SubscriptionStatus is a first-class state machine — the original Active/Canceled pair extended additively with the states a real subscription passes through:

State Serving? Meaning
Trialing yes Created with a trial; no charge yet. Converts at trial end.
Active yes The normal paying state.
PastDue yes A payment failed; still serving during dunning.
Paused no Temporarily suspended; no billing while paused.
NonRenewing yes A period-end cancellation is scheduled; still serving until it renews.
Canceled no Terminal; the org is on no plan.

isActive() is true for the four serving states (so entitlement projection and forfeiture treat a NonRenewing or PastDue subscription as still holding its plan); it is false for Paused and Canceled.

Transitions are deny-by-default: SubscriptionManager checks every status change against its allowed-transition table and throws Cbox\Billing\Subscription\Exceptions\IllegalStateTransition on an illegal one (resuming a canceled subscription, taking a paused one past-due, …) rather than applying it silently. The allowed moves:

create ─▶ Trialing (with a trial)   or   Active (without)
Trialing   ─▶ Active | Paused | PastDue | NonRenewing | Canceled
Active     ─▶ PastDue | Paused | NonRenewing | Canceled   (and Active, idempotent)
PastDue    ─▶ Active | NonRenewing | Canceled
Paused     ─▶ Active | Canceled
NonRenewing─▶ Active | Canceled
Canceled   ─▶ (terminal)
$sub = $manager->markPastDue($sub);  // payment failed  → PastDue
$sub = $manager->recover($sub);      // payment recovered → Active
$sub = $manager->pause($sub, $at);   // → Paused (records the pause instant)
$sub = $manager->resume($sub, $at);  // Paused → Active, period shifted by the paused span

renew honours the non-billing states: a Paused or Canceled subscription is returned untouched (no period advance, no charge), and a NonRenewing one is enacted to Canceled.

Trials

A subscription can begin in a trial — pass a trialEndsAt to create (or use the explicit startTrial). It opens Trialing, carries the trial end, and charges nothing during the trial. At trial end, convertTrial moves it to Active (the first charge is raised by the invoice/renewal path that observes the transition):

$sub = $manager->startTrial($id, $org, $productId, $priceId, $period, $trialEndsAt); // Trialing
$sub = $manager->convertTrial($sub, $at);   // Trialing → Active, first charge

A host that requires a payment method or commitment before charging can, per its own policy, route trial end to a non-serving state (cancelNow, or pause) instead of converting.

Ramp deals

A ramp steps the recurring price over a contract's term — a predetermined schedule of price changes rather than one scheduled by hand. A RampSchedule is an ordered set of RampSteps ({fromPeriodIndex, Money}); the step covering a period is the one with the greatest fromPeriodIndex ≤ index, so the amount holds until the next boundary:

$ramp = new RampSchedule([
    new RampStep(0, Money::ofMinor(10000, 'USD')),  // 100.00 for periods 0–2
    new RampStep(3, Money::ofMinor(15000, 'USD')),  // 150.00 from period 3 on
]);
$sub = $manager->withRamp($sub, $ramp);

Each renew advances the subscription's periodIndex, and effectiveRecurringAmount() resolves the ramp step for the current index — so the same subscription charges 100.00 through the first three periods and 150.00 thereafter, with no per-period scheduling. It composes with scheduleChange (price-id changes) independently: the ramp drives the recurring amount by index, scheduled changes re-pin the price id.

Minimum commitments & true-up

A MinimumCommitment is a minimum spend per billing period. At period close, if the period's actual charged amount (recurring + metered) falls below the minimum, a true-up line bills the shortfall so the commitment is met. The math is the pure, remainder-safe TrueUpCalculator the invoice/renewal path calls:

TrueUpCalculator::shortfall(Money $minimum, Money $actual): Money  // max(minimum − actual, 0)

$sub = $manager->withMinimumCommitment($sub, new MinimumCommitment(Money::ofMinor(10000, 'USD')));
$sub->trueUp(Money::ofMinor(7000, 'USD'));   // 30.00 shortfall → bill it
$sub->trueUp(Money::ofMinor(12000, 'USD'));  // 0 — the floor was already met

Everything is integer minor units, so the true-up is exact; a currency mismatch between the floor and the actual amount is refused by Money.

Preview equals charge (ADR-0007)

A plan-change or renewal preview must equal what is actually charged, to the cent — it is a promise shown at the confirm step. Two things break that:

  • Separate code paths for preview and charge drift apart.
  • Rounding a combined total diverges from the settlement gateway, which rounds each invoice line independently.

So one functionProrationCalculator — computes the proration/quote, and both the previewer (PlanChangePreviewer) and the charger call it. Identical inputs yield an identical Proration by construction, not by parallel maintenance. The previewer adds no arithmetic of its own.

Each line is rounded independently to whole minor units (matching the gateway, selected by GatewayRounding) before summing. The calculator is explicit about the edges:

  • Anchor (AnchorMode): keep-anchor prorates the delta to renewal; reset-anchor charges a fresh period minus unused base and can net a credit.
  • Deferred downgrades move no money.
  • Entering from pay-as-you-go charges a full fresh period with no credit.
  • A proration instant before the period start is clamped.
  • A zero-length period does not divide by zero.

Preview↔charge parity is testable directly: same inputs → same object. See ADR-0007.

Forfeiture on transition (ADR-0006)

Ending a subscription must forfeit the right credit pools. Forfeiture is keyed on the transition — an org leaving a subscription without landing on another, which covers cancel-to-null — not on a specific destination plan. A SubscriptionTransition drives the ForfeitureHandler:

interface ForfeitureHandler
{
    public function onTransition(SubscriptionTransition $transition, int $now): RemovalReport;
}

WalletForfeiture implements it against the wallet: only forfeitsOnCancel pools are affected and each is floored at zero, so a negative pay-as-you-go balance cannot offset a forfeitable allotment. See ADR-0006.

Fixed-term (registrar-style) products (ADR-0015)

A FixedTerm product — a domain, a hosting term, a certificate — is bought for a committed Term and billed as a TermSubscription: {id, orgId, productId, instanceRef, term, registeredAt, termEndsAt, autoRenew, status}, where instanceRef is the resource (the domain name). One org holds many instances, including many of the same product, each with its own term end and status.

TermLifecycle is the pure registrar state machine over an instance, its product's RegistrarWindows (a Term of grace + a Term of redemption), and an instant:

$phase = $lifecycle->phaseAt($instance, $windows, $now); // Active|Grace|Redemption|Expired
$renewed  = $lifecycle->renew($instance, new Term(1, TermUnit::Year), $now);   // → Active
$redeemed = $lifecycle->redeem($instance, new Term(1, TermUnit::Year), $now);  // Redemption → Active
$out      = $lifecycle->transferOut($instance, $now);                          // → TransferredOut
$fresh    = $lifecycle->transferIn($id, $org, $productId, $ref, $term, $now);  // new Active

Phase boundaries (inclusive upper edge): Active while now ≤ termEndsAt; Grace while now ≤ termEndsAt + grace; Redemption while now ≤ termEndsAt + grace + redemption; otherwise Expired. renew extends termEndsAt by the new term from the later of now / termEndsAt (early renewal stacks; late renewal extends from now). TransferredOut and Cancelled are terminal — phaseAt preserves them.

Auto-renew boundary: with autoRenew = true, passing the term end does not enter Grace — the instance stays Active and isAutoRenewalDue reports a renewal is due, so a billing run charges the Renewal price and extends the term. Grace is the manual-lapse path only.

Purchasing goes through the same pipeline as everything else. TermPurchase selects the (term × kind) price via Catalog::termPriceFor and produces the LineInput the shared QuoteBuilder taxes and applies credit to; the Invoicer issues it. Register / Renewal / Redemption / Transfer are just a choice of PriceKind — tax, seller-of-record, credits, and dunning are unchanged.

$quote = $termPurchase->quote($product, new Term(2, TermUnit::Year), PriceKind::Register, 1, $context, $now);

The registry/EPP/DNS integration — auth codes, actual provisioning, real transfer orchestration — is out of scope: a connector concern. The engine owns only the commercial lifecycle and the money movements it implies.

Testing

Cbox\Billing\Subscription\Testing\InteractsWithSubscriptionLifecycle and FakeForfeitureHandler drive lifecycle transitions and assert the forfeiture fired on the right transition.