RouteNest is about to raise Pro monthly from $19 to $24. If the system stores one mutable row—plan=pro, price=19, stripe_price_id=...—writing 24 over 19 removes the basis for existing subscriptions. Whether the $190 annual price moves, whether the euro price is recomputed, which amount WELCOME20 discounts, and which Paddle or Creem object to charge all become guesses.

How to model SaaS products and prices depends on when each fact changes. Product says what is sold. Plan says which features and limits the buyer receives. Price says amount, currency, interval, and version. A promotion only changes a qualifying sale. A provider mapping only projects an internal Price onto a payment channel. Orders and subscriptions must store the snapshot from the sale; they cannot reread “the current price” to explain the past.

The charge metric, audience, and entitlements are already decided, and the payment account has passed the reviews it needs. If per-seat, usage, or feature packaging is still open, finish that product decision first. If the account is still collecting documents, finish the review packet before creating live price objects. RouteNest, object IDs, amounts, and dates are a synthetic case, not a live merchant.

How to model SaaS products: what Product, Plan, and Price own

RouteNest Pro currently stores name, feature list, $19, monthly billing, a Stripe Price ID, and a welcome discount in one place. Those facts do not share a lifecycle. Product identity rarely changes. Entitlements change when packaging changes. Amount, currency, and interval change when you sell a new commercial term. A promotion has a window and eligibility. A channel object also carries processor, merchant account, and test or live environment.

ObjectFacts it ownsRouteNest exampleMust not include
ProductProduct identity, purpose, brand display, and business homeRouteNest team scheduling software$19, monthly billing, Stripe Price ID
PlanA sellable set of features, limits, and service levelPro: 20 members, schedule export, audit logCurrency, promo code, channel account
PriceAmount, currency, interval, charge model, and version for one PlanPro / USD / monthly / $19 / v1Feature flags, customer eligibility, processor keys
PromotionDiscount type, scope, eligibility, window, and redemption limits20% off the first paid orderPermanent list price, plan entitlements
Provider MappingInternal Price to one channel account, environment, and external objectv2 → a Stripe live PriceProduct meaning, sale-amount source of truth
Transaction SnapshotProduct, price, promotion, and paid amount used on this purchaseList $24, discount $4.80, paid $19.20A lookup that future catalog updates can overwrite

Plan is the internal service-commitment layer. It does not need a same-named object at every processor. It can bind to permissions and quotas, but “what Pro includes today” cannot be the only answer for every historical subscription. If Pro moves from 20 members to 50 and old customers keep the original entitlement, create a Plan revision or a new Plan and leave old subscriptions on the old promise. Display copy can change in place only when delivery does not change.

A Price identity is more than an amount. It at least names the Plan, currency, interval, charge model, and version. 19 does not say USD or EUR, or monthly, annual, or one-time. Usage, tiered, or mixed billing still belongs on Price through a unit and formula; raw usage events and invoice math stay in the later billing path, not on Product or Plan.

Product defines the offering, Plan defines entitlements, Price defines currency, interval, and version; a promotion overlays the price, a mapping executes it, and a transaction snapshot stores the sale
Catalog, promotion, channel, and sale each own one class of fact. Arrows are references, not permission to rewrite upstream.

Keep the canonical catalog in your own system

Checkout, quotes, subscriptions, refunds, and entitlements should resolve an internal Product, Plan, and Price first. A processor ID is the execution projection of that Price, not the catalog itself.

Stripe’s catalog is the reference execution model for one processor: a Product(opens in a new tab) is what you sell, and a Price(opens in a new tab) is how much and how often you charge. For SaaS, Stripe tells you to create a Product per pricing-page row, attach monthly and annual Prices, and create a new Price and archive the old one(opens in a new tab) when the amount changes. That is a sound way to charge through Stripe. It is not a complete internal catalog. Stripe has no first-class Plan for entitlements; a Stripe Product is often the packaging row. Copying that tree into the app collapses product identity and plan packaging, then leaves no place for a second processor.

Paddle also splits Product and Price(opens in a new tab), and it lets you update unit_price on an existing Price(opens in a new tab). Lemon Squeezy puts the sellable option on a Variant(opens in a new tab), with a related Price(opens in a new tab) for the charge model. Creem puts amount, currency, and billing period on Product(opens in a new tab), so monthly and yearly are separate Products(opens in a new tab). If the database copies any one of those trees, switching channels rewrites Product, Plan, or Price.

The internal objects stay stable. Provider mappings absorb the external hierarchy:

Internal objectStripePaddleLemon SqueezyCreem
ProductProductProductProductNo separate product-only object
PlanNo same-named object; often the Stripe Product rowNo same-named objectVariantSeparate Product per tier
PricePricePricePrice on a VariantAmount, currency, and period on Product

Plan does not need a same-named object at every processor. The app still needs Plan, because entitlements, list price, and channel IDs do not share a lifecycle. A mapping may point at a Stripe Product plus Price, a Lemon Squeezy Variant, or a Creem Product; that does not make the internal Plan “a Stripe Product.”

The app keeps stable IDs such as product_routenest, plan_pro_r1, and price_pro_usd_month_v1. Display names can change. External objects can retire. Those IDs still join subscriptions, orders, invoices, and audit events. If business code compares "Pro", "monthly", or a channel ID in many places, the catalog boundary is not actually in place.

Currency and interval define one sellable price

When RouteNest Pro sells USD and EUR monthly and annual prices, it needs four Prices. Those four commercial terms do not belong as optional fields on Plan.

Internal PricePlanCurrencyIntervalAmountCurrent use
price_pro_usd_month_v1Pro r1USDevery 1 month$19USD monthly new sales and old subscriptions
price_pro_usd_year_v1Pro r1USDevery 1 year$190USD annual
price_pro_eur_month_v1Pro r1EURevery 1 month€18EUR monthly
price_pro_eur_year_v1Pro r1EURevery 1 year€180EUR annual

$190 annual can appear on the pricing page as “$38 less than twelve monthly payments.” That $38 is a comparison of two list prices, not a checkout-time promotion. Annual renewal, contract amount, and later change dates can differ from monthly, so annual is itself a Price.

A buyer in Germany should not cause the system to convert $19 into euro and overwrite the original row. Region, billing address, and language can take part in Price selection. The result still has to resolve to one EUR Price. A one-off FX quote can serve the current transaction. It cannot quietly become the catalog price for the next automatic renewal.

Stripe allows several currency_options on one Price(opens in a new tab). Paddle can override price by country(opens in a new tab). Those capabilities can reduce channel objects. They are a poor internal model across processors. Keep one internal Price per currency, interval, and version, then let mapping decide whether several internal Prices may safely point at one external object. Webhook reverse lookup, refunds, and migration export then keep the same grain.

The same Plan, currency, and interval resolve to one current version at one selling moment. A planned effective time can switch v1 and v2, but a time window does not replace status. A draft Price is not selectable in Checkout even after its start time. A retired Price does not reappear because its end time is empty.

A promotion overlays a price; it is not another list price

WELCOME20 takes 20% off USD monthly, first term only, new customers only. The list price stays $19. The first invoice is $15.20. If that 20% is written into Price, raising the list price silently raises the discount, and the discount never expires.

Keep three amounts on a qualifying sale:

AmountSourceExample
ListPrice$19.00, or 1900 cents
DiscountPromotion$3.80, or 380 cents
PaidList minus discount, after rounding$15.20, or 1520 cents

A promotion also has a scope. WELCOME20 applies to price_pro_usd_month_v1 only. Annual $190 and EUR monthly €18 are out of scope. Changing the dollar monthly Price to v2 does not move the promotion by itself; the promotion’s scope has to include v2, or the code stops applying.

Do not reuse Price for these neighboring facts:

FactOwnerWhy it is not a Price
$19 USD monthlyPrice v1Sellable list terms
$190 USD annualAnother PriceDifferent interval and amount
20% off first monthPromotionEligibility, window, and use count
Code WELCOME20Promotion code / coupon objectDistribution; several codes can share one promotion
$15.20 actually paidTransaction snapshotComputed for this sale

Stripe splits coupons from promotion codes(opens in a new tab): the coupon is the discount rule, the code is what the customer types. Paddle discounts(opens in a new tab) can be percent or flat, limited by product, price, time, or redemption count. Lemon Squeezy discounts(opens in a new tab) can bind to variants, set a window, and apply once, repeating, or forever. The channel object is a mapping target. Eligibility, stacking, and “already used” still belong in the app, because Checkout, quotes, and customer service have to explain the same rule.

A quote stores list, discount, paid amount, Price version, and promotion revision. After WELCOME20 expires, that quote still shows $15.20. A new quote on the same Price returns to $19.

Change a price by creating a version

On 2026-10-01 00:00 UTC, new USD monthly sales should charge $24. Existing monthly subscribers should stay on $19. The wrong write is UPDATE prices SET amount=24 WHERE plan='pro'. After that, old subscriptions, invoices, refunds, and “what did this customer buy?” all reread $24.

Create price_pro_usd_month_v2 at $24, same currency, interval, and Plan. At the switch, v1 stops taking new sales and v2 becomes the current sellable version. Subscriptions already on v1 keep v1. Annual and euro Prices do not move unless they have their own versions.

At the switch, the $19 Price v1 leaves new sales but still serves old subscriptions; the $24 Price v2 takes new Checkout after its channel mappings are ready
The switch changes which version new sales use. Whether old subscriptions migrate is a separate contract-and-notice job.

Stripe’s documented price change is the same move: create a new Price and archive the old one(opens in a new tab), because the old Price has to remain an immutable record of past transactions. After a Stripe Price exists, only metadata, nickname, and active can change(opens in a new tab). Paddle can PATCH unit_price(opens in a new tab). That is a channel capability, not permission to overwrite the internal Price. If Paddle’s object is edited in place, the internal version still splits, and the mapping points at a new external object or a new mapping revision.

Quotes freeze the Price version they were issued on. A quote created on 2026-09-28 for $19 stays $19 until it expires, even if the catalog has already switched. After expiry, a new quote resolves the current sellable version. Old-subscription migration is not a catalog switch. It needs a contract, customer notice, and an execution record. Until that exists, a renewal continues to reference v1.

Retiring a price stops new sales; it does not delete history

v1 can leave the pricing page and still be required. Renewals, refunds, disputes, webhooks, invoices, and audit still look up $19, the original mapping, and the original promotion snapshot. Deleting v1, or overwriting it to $24, makes those paths invent a number.

StatusNew salesExisting subscriptionsTypical next action
draftNoNone yetFinish mappings; do not publish Checkout
activeYesNew sales may create someCurrent sellable version
retiredNoKeep original PriceStay until those subscriptions end or are migrated
archivedNoNo remaining referencesHide from operations after refund and audit windows

Stripe archives a Price(opens in a new tab) so it cannot be added to new invoices or subscriptions; existing subscriptions on that Price stay active until they are canceled. Paddle has no delete for prices(opens in a new tab); you archive through update. Lemon Squeezy disabled variants(opens in a new tab) disappear from checkout, while buyers of the old variant keep access. The internal retired flag can line up with those actions. It is not a substitute for keeping the internal record.

Product, Plan, Promotion, and mappings have their own lifecycles. Renaming RouteNest does not create a new Product. Changing Pro from 10 seats to 15 is a Plan revision: new subscribers get r2, and r1 subscribers stay on 10 seats unless notice says otherwise. WELCOME20 reaching its end date stops new redemptions; first invoices that already used it keep the $15.20 snapshot. A mapping moves to retired when the channel object is no longer used for new sales. Old subscriptions still point at the old mapping.

grandfathered is a relationship, not a Price status. The subscription still references price_pro_usd_month_v1 and plan_pro_r1. The catalog does not grow a parallel “legacy Pro.” The old Price leaving new sales is enough.

Provider objects differ; each mapping still points at one internal Price

price_pro_usd_month_v2 can be sold on several channels. Each channel needs its own mapping. The mapping stores processor, merchant account, environment, execution mode, external object type, external ID, and a check of amount, currency, and interval. It does not become a second list price.

ProcessorExternal objectMaps from this internal PriceReady when
Stripeprice_... under a Productprice_pro_usd_month_v2Amount, currency, and interval match; test and live IDs are separate
PaddlePrice under a ProductSameSame checks; country overrides do not rewrite the internal currency
Lemon SqueezyVariant, plus Price when the model must be recordedSameVariant and Price both point back to this version
CreemProduct that includes amount and periodSameMonthly, annual, and tiers are not collapsed onto one Product
The same internal Price maps to a Stripe Price, a Paddle Price, a Lemon Squeezy Variant and Price, and a Creem Product
External object trees can differ. Every mapping still lands on one internal price version, one account, and one environment.

Creem’s create-product(opens in a new tab) payload includes price, currency, billing_type, and billing_period. Monthly and yearly, or Basic versus Pro, are separate Products(opens in a new tab). Test and live catalogs are also separate; copying a test Product ID into live fails. That is a mapping constraint. It is not a reason to split the internal Price.

A mapping is ready only after the checks pass. Stripe $24 USD monthly can be ready. Paddle still pending means that channel cannot sell v2 yet. Checkout then returns “this version is not for sale here,” not a handmade $24 line.

Reverse lookup uses the same uniqueness. A webhook that only carries price_xxx is incomplete. Processor, account, environment, and object ID together find one mapping, then one internal Price, then the subscription or order. Two live Stripe accounts, or a test ID reused in live, make that lookup collide.

A sale cannot be reconstructed from the current catalog

After 2026-10-01, the current sellable USD monthly Price is $24. An order from 2026-09-12 still has to show $19, or $15.20 with WELCOME20. Rereading the catalog produces the wrong invoice, refund, and tax base.

Keep three amount objects:

ObjectWhen it changesWhat it explains
Catalog PriceNew version; old version staysCurrent sellable terms, and historical terms still referenced
QuoteFrozen at issue; repriced only after expiryAmount the buyer was offered
Transaction snapshotWritten at sale; later catalog edits do not rewrite itAmount the buyer paid, and the objects used

The snapshot at least stores Price ID and version, list amount, discount amount, paid amount, currency, interval, promotion revision, mapping revision, external object identity, and created-at. Refunds and disputes recompute from that snapshot. They do not call “current Pro price.”

If Stripe later archives price_old and Paddle later changes a Price’s unit_price, the order still has 1900 cents list and 1520 cents paid. The mapping revision says which external object collected the payment. Without that snapshot, a channel change looks like the customer paid $24.

How RouteNest records the change from $19 to $24

Today the catalog has four v1 Prices, WELCOME20 r1 on USD monthly, and ready mappings for those v1 Prices. The 2026-10-01 change is only USD monthly, and old subscribers stay on $19.

Create price_pro_usd_month_v2 at $24 in draft. Create Stripe, Paddle, Lemon Squeezy, and Creem objects for $24 USD monthly, then mappings that stay pending until amount, currency, and interval match. Decide WELCOME20 scope: r1 stays on v1 only, or r2 includes v2. In this case r2 applies 20% to v2, so a new customer on 2026-10-02 pays $19.20, not $15.20.

At 2026-10-01 00:00 UTC, v1 becomes retired, v2 becomes active, and ready v2 mappings start taking Checkout. Subscriptions on v1 are not rewritten. Unexpired quotes keep v1. A later webhook with a v1 Stripe Price ID still reverse-maps to price_pro_usd_month_v1.

TimeEventCatalog objects usedVisible amount
2026-09-12Old subscriber renewsv1 + original mapping + original snapshot$19.00
2026-10-02New monthly + WELCOME20v2 + r2 + ready mappingList $24.00, paid $19.20
2026-10-02New annualprice_pro_usd_year_v1 unchanged$190.00
2026-10-02New EUR monthlyprice_pro_eur_month_v1 unchanged€18.00

Four facts stay independent. Annual and euro did not change, so they have no v2. Old subscriptions did not migrate, so they still reference v1. The promotion did not become a new list price. Each channel has its own v2 mapping; a pending Paddle mapping does not block a ready Stripe Checkout.

After the switch, create a new Checkout and read one old subscription. The new Checkout must use v2. The old subscription must still reference v1. If both show $24, the catalog or the subscription Price was overwritten.

Each failure belongs to one object

New Checkout still charges $19: see whether the request resolved v1 or v2, then whether v2’s mapping is ready. Do not also write $24 onto the Plan to “align” it. Old subscriptions jumping to $24 mean an external object was mutated in place, or subscription Prices were bulk-replaced. Stop the bad migration, restore subscriptions to the old Price, and check the affected renewals and notices. Do not move v2 back to $19. Then create a new Checkout and read one old subscription: the former uses v2, the latter still references v1.

WELCOME20 working after expiry is a Promotion window, eligibility, or channel-discount mapping problem, not a base Price problem. Stop new bad redemptions, fix the rule, and issue a new quote. The new quote should return to the $24 list price. Snapshots already sold stay as they were.

A webhook with an external Price ID and no internal order should stop before it advances order status. Repair the reverse mapping with processor, account, environment, and object identity, then replay the event idempotently. Creating another Product with the same name does not restore the lost version relationship. Test Checkout succeeding while live reports a missing object usually means a test mapping entered live. Create and verify a live mapping in the live account, then recheck with a new Checkout. Do not flip the test object’s environment field to live.

If an old customer suddenly sees new Pro entitlements, restore the Plan revision bound to that subscription and recompute entitlements. If a historical order opens at $24, restore the original Price reference and sale snapshot on the order line. Recheck: the old subscription shows the original entitlements, and the old order still shows $19. Putting every fact back on one plans table only makes the next change overwrite the last.

Which fact changed decides which object to create or update:

Fact that changedCorrect actionOld relationship to keep
Name, description, or image; same product promiseUpdate Product display fieldsStable Product ID and historical snapshots
Product purpose, delivery type, or business meaningCreate a new Product and recheck processor coverageOriginal Product and its orders, subscriptions, and policy evidence
Features, limits, or service levelCreate a Plan revision and say who stays grandfatheredPlan revision and entitlement promise on old subscriptions
Amount, currency, interval, or charge modelCreate a Price version and prepare new mappingsOld Price, old mappings, and sale snapshots
Promotion scope, window, eligibility, or usesCreate a Promotion revisionAlready redeemed promotions and their order or subscription snapshots
Processor, merchant account, environment, or external objectCreate a new Provider MappingMapping revision and external identity used by historical transactions

After this model is in place, $19 is no longer a field that the next price overwrites. It is v1: out of new sales, still able to explain old subscriptions. $24 is v2, and it only takes new sales. Each generation has its own channel mappings, and each order stores its own sale snapshot. When the destination channel’s v2 mapping is ready, the next job is choosing hosted Checkout, an embedded payment, or a custom checkout so the buyer can pay.