Plan retirement & sunset
Plan retirement & sunset
A plan status of Legacy marks a plan not offered to new subscribers — but a legacy
plan may be held indefinitely. That is wrong for an early-access / beta / demo plan,
or any plan a business must genuinely discontinue: those need a hard cutoff by which
existing subscribers are moved off. Plan retirement (ADR-0016)
adds that — a forced migration off a plan by a date, with no paid time lost.
The cutoff
A plan carries an optional PlanRetirement:
use Cbox\Billing\Catalog\ValueObjects\PlanRetirement;
use Cbox\Billing\Catalog\ValueObjects\Product;
use Cbox\Billing\Catalog\Enums\PlanStatus;
$beta = new Product(
id: 'beta',
name: 'Early access',
family: 'hosted',
status: PlanStatus::Retiring,
retirement: new PlanRetirement(
retiresAt: new DateTimeImmutable('2026-06-01'),
defaultSuccessorPlanId: 'hosted-pro', // optional
),
);
retiresAt— the cutoff. From this instant the plan is retired.defaultSuccessorPlanId— where a subscriber who makes no choice lands (optional).
PlanStatus::Retiring labels the intent; retiring-ness is otherwise computed from the
PlanRetirement, so the two never drift. A retiring plan is never a valid transition
target — no subscription may switch onto a plan that is being sunset (the
FamilyTransitionPolicy refuses it, like a legacy target).
Retiring differs from Legacy in that it is forced: a legacy plan may be held
forever; a retiring plan has a dated cutoff that resolves existing subscribers off it.
Resolve at the next renewal on/after the cutoff
Retirement is enacted at the subscriber's next renewal on/after retiresAt — never
mid-period — so no one loses time they already paid for. The pure PlanRetirementResolver
maps (Subscription, Catalog, now) to one of six outcomes:
| Outcome | When | Effect |
|---|---|---|
NotRetiring |
No retirement, or now is before the cutoff |
Renew normally |
ResolvedToSuccessor |
The subscriber scheduled a successor plan | Migrate to it (policy-validated) |
ResolvedToCancel |
The subscriber scheduled a cancel | Cancel at the renewal |
RetiringChooseBy |
Retired, no choice yet, paid time remains | Informational: choose by the renewal-due date |
ResolvedToDefault |
Retired, renewal due, no choice, a default is set | Migrate to the default (policy-validated) |
UnresolvedRetirement |
Retired, renewal due, no choice, no default | Refused — deny-by-default |
$resolution = $resolver->resolve($subscription, $catalog, $now);
$resolution->outcome; // a RetirementOutcome
$resolution->successorPlanId; // for ResolvedToSuccessor / ResolvedToDefault
$resolution->renewalDueDate; // the deadline, for RetiringChooseBy
The three choices, plus a default and a deny
- Migrate to a successor. The subscriber schedules a plan change onto another product —
SubscriptionManager::schedulePlanChange($sub, $newProductId, $newPriceId, $effectiveAt). At the forcing renewal this resolves toResolvedToSuccessorand migrates onto the chosen plan and price. - Cancel. The subscriber schedules a period-end cancel (
cancelAtPeriodEnd) — a first-class, equal choice, not a fallback. They keep serving until the renewal, then end (ResolvedToCancel). - Do nothing → default. With no choice, a configured
defaultSuccessorPlanIdcatches them (ResolvedToDefault). - Do nothing, no default → deny. With no choice and no default, the renewal yields
UnresolvedRetirement: the plan does not silently keep renewing (and charging). The host must surface the decision.
Enact it: the renewal policy
RetirementRenewalPolicy is a thin seam a host calls in place of
SubscriptionManager::renew() for subscriptions that may be on a retiring plan. It resolves,
then enacts:
use Cbox\Billing\Subscription\Retirement\RetirementRenewalPolicy;
use Cbox\Billing\Subscription\Retirement\Exceptions\RetirementNotResolved;
try {
$renewed = $policy->renew($subscription, $catalog, $nextPeriod, $now);
} catch (RetirementNotResolved $e) {
// Unresolved retirement — surface it; do NOT renew or charge the retired plan.
} catch (\Cbox\Billing\Subscription\PlanChange\Exceptions\TransitionNotAllowed $e) {
// The successor is an illegal target for this subscription — surface the reason.
}
NotRetiring,RetiringChooseBy, andResolvedToCancelrenew normally (the last enacts the already-scheduled cancel) — behaviour identical to callingrenew()directly, so non-retiring subscriptions are completely unaffected.ResolvedToSuccessor/ResolvedToDefaultmigrate onto the successor, validated through theTransitionPolicy(ADR-0010): an illegal successor (e.g. a cross-family jump with no declared edge) raisesTransitionNotAllowedrather than silently migrating.UnresolvedRetirementraisesRetirementNotResolved— the renewal is refused, never a silent charge on a retired plan.
The policy owns no arithmetic: it delegates every state change to the
SubscriptionManager (renewOntoPlan() migrates and advances the period in one step), so
the state machine stays the single source of truth.
Testing
Cbox\Billing\Catalog\Testing\InteractsWithCatalog::retiringPlan() builds a plan with a
cutoff, and Cbox\Billing\Subscription\Testing\InteractsWithSubscriptionLifecycle exposes
retirementResolver() and retirementRenewalPolicy() to drive resolution and enactment.