Plan changes & grandfathering
Change prices safely; existing subscribers keep their terms by default.
Change prices safely; existing subscribers keep their terms by default.
You change a plan the same way you author it: edit the @Plan in your product
repo and publish. The key safety property is grandfathering — when you
reprice or restructure a plan, existing subscribers keep the terms they signed
up under. A new price applies to new subscribers immediately; current
subscribers move only when their billing period rolls over, and only for changes
that are safe to apply that way. Actively pulling existing subscribers onto a new
version is a separate, explicit operate action — never a side effect of
publishing.
This split is deliberate. A @Product class is a snapshot of what the product
is now. Moving live subscribers is a transition over real billing state — so it
is an operate verb (farthershore plan migrate), not something a declaration can
silently trigger.
@Plan("pro", {
name: "Pro",
price: { amount: 24900, currency: "usd", interval: "month" }, // was 19900
limits: { requests: { rate: 6000, interval: "minute", enforcement: "enforce" } },
})
pro!: unknown;
# Commit product/ and push — the GitHub bot validates and applies the release.
git add product/ && git commit -m "raise Pro to $249" && git push
# Or cut the release yourself; the bump is auto-derived from the change:
farthershore product publish croncloud
farthershore product publish croncloud --dry-run # preview the bump + reasons
Publishing cuts a new plan version. New subscribers get $249. Everyone
already on Pro keeps $199 until you migrate them — that's the default.
A live product's contract is managed as code. Editing pricing through the API
or CLI returns MANAGED_BY_CODE (exit 4) — author the change in product/ and
push instead. A breaking change is refused by publish unless you pass
--accept-breaking.
subscriberChangePolicyThe product's billing.subscriberChangePolicy decides, per kind of change,
whether an existing subscriber switches immediately or at period_end
(their next renewal — the grandfathering window). It's authored on the product
billing block via @Raw (the typed sugar lives at the platform schema, so use
the escape hatch):
import { Product, Plan, Raw } from "@farthershore/product";
@Product({ name: "croncloud", origin: "https://api.example.com" })
@Raw({
productPatch: {
billing: {
subscriberChangePolicy: {
default: "period_end",
when: {
price_increase: "period_end", // grandfather raises until renewal
price_decrease: "immediate", // pass savings along now
feature_added: "immediate", // more value, apply now
feature_removed: "period_end",
limit_increased: "immediate",
limit_reduced: "period_end",
},
// Consent gates — required to apply the harsher direction immediately:
allowImmediatePriceIncrease: false,
allowImmediateEntitlementReduction: false,
},
},
},
})
export default class CronCloud {
@Plan("pro", {
name: "Pro",
price: { amount: 24900, currency: "usd", interval: "month" },
limits: { requests: { rate: , : , : } },
})
pro!: ;
}
The defaults are subscriber-friendly: price increases and entitlement
reductions wait for period_end (grandfathered), while price decreases and
upgrades apply immediately. Timing is the only knob — Stripe owns the proration
math at switch time.
The consent gates are enforced at compile time. Setting price_increase: "immediate" without allowImmediatePriceIncrease: true fails validation; the
same guard covers feature_removed, limit_reduced, and credit_reduced under
allowImmediateEntitlementReduction. You can't accidentally yank terms from a
paying subscriber mid-period.
farthershore plan migrateWhen you do want existing subscribers off an old plan version, run the operate verb. It takes the plan key plus the source/target versions and a policy.
farthershore plan migrate croncloud pro --from 1 --to head --policy next_renewal
farthershore plan migrate croncloud pro --from 1 --to 2 --policy immediate --proration prorate
<productIdOrName> <planKey> — the product and the stable plan key (pro).--from <version> / --to <version> — integers, or head/latest.--policy <policy> — one of:
grandfather — keep existing subscribers as-is (no move).next_renewal — migrate each subscriber at their own renewal.immediate — migrate everyone now.by_date — migrate everyone by a deadline (pass --complete-by <iso8601>).opt_in — subscribers choose whether to move.--proration <none|prorate|credit> — optional; how Stripe settles the switch.This is an OPERATE-class action: it's allowed on code-managed products (it
changes live subscriber state, not the product definition), it's idempotent, and
it's available over MCP as fs_plan_migrate. Preview first with --dry-run.
farthershore plan migrate croncloud pro --from 1 --to head --policy next_renewal --dry-run
You cannot delete a plan that still has subscribers — the API returns
PLAN_HAS_ACTIVE_SUBSCRIPTIONS. Migrate them off it first (above), or retire it
gracefully with the plan's archive option (strategy: "auto" | "explicit" | "block", with an optional transitionTo plan), then publish.