Good to see you again. In the previous lesson, you separated the billable owner from the authenticated actor and from seat assignees. That gives subscriptions a stable commercial owner such as an individual account or organization, without coupling billing to your host application’s membership tables.
Now we define what can be bought. The central design decision is that a subscription must reference a specific historical version of an offering—not merely a mutable label such as pro. By the end of this lesson, you will have a TypeORM model for versioned plans, fixed and per-seat recurring prices, currencies, billing intervals, feature entitlements, and retirement of offerings.
1. Treat a plan as a commercial snapshot
A plan name such as “Pro” is not enough to bill correctly. Over time, “Pro” might change from:
- $49/month plus $8 per seat
- to $59/month plus $10 per seat
- while adding advanced reporting
- and changing the annual discount.
If you overwrite a single plans row, existing subscribers silently acquire a different commercial agreement. That breaks invoice consistency, customer expectations, and reconciliation with a billing provider.
Instead, distinguish:
| Concept | Example | Mutability |
|---|---|---|
| Plan code | pro | Stable product-line identifier |
| Plan version | pro, version 3 | Immutable commercial definition after publication |
| Price component | fixed recurring fee; per-seat recurring fee | Immutable child of a plan version |
| Entitlement | analytics.export = true | Immutable child of a plan version |
| Retirement | “No longer available for new checkout” | Availability metadata; historical record remains |
A subscription created on pro version 2 must continue to resolve its price components and entitlements from version 2. A new checkout can select version 3.
Monetization Best Practices in REST API Design | Speakeasy
Read the sections on immutable pricing definitions and decoupled entitlements in Speakeasy’s monetization guide. They establish why versioning is a billing requirement, rather than just a convenient database pattern.
In “Design principles that support pricing flexibility,” read the case for versioning, then continue through the subsections “Create a single source of truth for your immutable pricing definitions” and “Use modular entitlement checking that is decoupled from business logic.” Focus on the distinction between changing a plan for future customers and changing the agreement of an existing subscriber.
The immutability rule
A practical rule for this module is:
Once
publishedAtis set, do not update a plan version’s commercial fields or its price and entitlement rows. Create a new version instead.
Commercial fields include:
- plan code and version;
- display name and description shown to subscribers;
- currency;
- billing interval;
- fixed and seat prices;
- entitlement keys and values.
A plan version can still receive a retiredAt timestamp. Retirement means new checkout cannot choose it. It does not delete the plan, alter existing subscriptions, or cancel anyone’s service.
2. Model hybrid recurring pricing explicitly
Your module needs to support both a platform fee and a quantity-based seat fee in the same subscription.
For example, an organization on a Pro plan may pay:
| Price component | Amount | Quantity | Monthly subtotal |
|---|---|---|---|
| Fixed recurring | $49.00 | 1 | $49.00 |
| Per-seat recurring | $8.00 | 12 seats | $96.00 |
| Total before tax | $145.00 |

The plan version owns the shared commercial context:
- one ISO-style three-letter currency code, such as
USD; - one recurring interval: month or year;
- an interval count, such as every 1 month or every 12 months.
Each price component then answers a narrower question: what is charged at that cadence?
export const BILLING_INTERVAL_UNITS = ['month', 'year'] as const;
export type BillingIntervalUnit =
(typeof BILLING_INTERVAL_UNITS)[number];
export const PRICE_KINDS = [
'fixed_recurring',
'per_seat_recurring',
] as const;
export type PriceKind = (typeof PRICE_KINDS)[number];
This lesson uses a deliberate invariant: every component in one plan version shares the plan’s currency and cadence. It prevents ambiguous configurations such as an annual platform fee combined with independently monthly seats. If your product later needs that behavior, model it as a distinct offering with explicit provider support and proration rules.
A plan version may have:
- a fixed fee only;
- a per-seat fee only;
- both fixed and per-seat fees.
It must have at least one price component, and it can have no more than one component of each kind.
3. Money: store minor units, never floating point
Do not persist monetary amounts as JavaScript floating-point values or PostgreSQL real or double precision columns. Binary floating point represents many decimal fractions approximately, which makes equality checks and totals unreliable.
Storing money in MySQL (the right way)
Watch PlanetScale’s “Storing money in MySQL (the right way)” for the integer-storage approach. Although the demonstration uses MySQL, the precision issue and the minor-unit strategy apply equally to PostgreSQL.
Watch integer storage. Focus on the conversion from a currency amount to its smallest denomination and why integer arithmetic remains exact.
Store price amounts as integer minor units:
| Displayed amount | Currency | Stored amountMinor |
|---|---|---|
| $49.00 | USD | "4900" |
| $8.00 | USD | "800" |
| ¥1,200 | JPY | "1200" |
| 12.345 dinars | KWD | "12345" |
“Minor unit” is better terminology than “cents.” Not every currency has two decimal places. The currency determines the exponent used only when formatting or parsing an amount at the system boundary.
For PostgreSQL, use bigint. TypeORM’s PostgreSQL driver commonly materializes bigint values as strings, which is useful here: it avoids accidentally converting a large integer into an unsafe JavaScript number.
export interface Money {
amountMinor: string;
currency: string;
}
Your command DTOs and administration UI should accept a validated integer string such as "4900", not "49.00" and never a float. A presentation layer can convert minor units to a localized display amount, but the billing domain should preserve the exact integer value.
4. TypeORM entities for versioned plans
Use ordinary PostgreSQL tables, foreign keys, and constraints. Avoid synchronize: true outside local development; the next lesson on migrations will turn this entity model into reviewed, deployable PostgreSQL schema changes.
The following model uses TypeScript string unions plus PostgreSQL check constraints instead of PostgreSQL native enums. That keeps the schema easier to evolve through explicit migrations.
import {
Check,
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
} from 'typeorm';
export const BILLING_INTERVAL_UNITS = ['month', 'year'] as const;
export type BillingIntervalUnit =
(typeof BILLING_INTERVAL_UNITS)[number];
export const PRICE_KINDS = [
'fixed_recurring',
'per_seat_recurring',
] as const;
export type PriceKind = (typeof PRICE_KINDS)[number];
export type EntitlementValue =
| boolean
| number
| string
| Record<string, unknown>;
@Entity('plan_versions')
@Index('UQ_plan_versions_code_version', ['planCode', 'version'], {
unique: true,
})
@Check(
'CHK_plan_versions_interval_unit',
`"billing_interval_unit" IN ('month', 'year')`,
)
@Check(
'CHK_plan_versions_interval_count',
`"billing_interval_count" >= 1`,
)
@Check(
'CHK_plan_versions_retirement_after_publication',
`"retired_at" IS NULL OR (
"published_at" IS NOT NULL
AND "retired_at" >= "published_at"
)`,
)
export class PlanVersionEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
// Stable product-family identifier, for example "starter" or "pro".
@Column({ type: 'varchar', length: 64, name: 'plan_code' })
planCode!: string;
// Starts at 1 and increases only by creating another row.
@Column({ type: 'integer' })
version!: number;
@Column({ type: 'varchar', length: 160, name: 'display_name' })
displayName!: string;
@Column({ type: 'varchar', length: 1_000, nullable: true })
description!: string | null;
// ISO-style code, validated by application-level catalog rules.
@Column({ type: 'char', length: 3 })
currency!: string;
@Column({
type: 'varchar',
length: 16,
name: 'billing_interval_unit',
})
billingIntervalUnit!: BillingIntervalUnit;
@Column({
type: 'smallint',
name: 'billing_interval_count',
default: 1,
})
billingIntervalCount!: number;
// Null means draft. A published version is commercially immutable.
@Column({
type: 'timestamptz',
name: 'published_at',
nullable: true,
})
publishedAt!: Date | null;
// Retirement removes it from new-checkout catalog queries.
@Column({
type: 'timestamptz',
name: 'retired_at',
nullable: true,
})
retiredAt!: Date | null;
@CreateDateColumn({
type: 'timestamptz',
name: 'created_at',
})
createdAt!: Date;
@OneToMany(() => PlanPriceEntity, (price) => price.planVersion)
prices!: PlanPriceEntity[];
@OneToMany(
() => FeatureEntitlementEntity,
(entitlement) => entitlement.planVersion,
)
entitlements!: FeatureEntitlementEntity[];
}
Notice what this entity does not contain:
- no
currentPrice; - no
isPro; - no seat count;
- no provider product ID;
- no owner reference;
- no subscription status.
Those belong to other aggregates. A plan version is a reusable catalog definition. A future subscription chooses it; the subscription supplies the purchased seat quantity and identifies the billable owner.
5. Price and entitlement child entities
A plan version has child rows because prices and entitlements are part of the versioned commercial snapshot.
@Entity('plan_prices')
@Index('UQ_plan_prices_version_kind', ['planVersionId', 'kind'], {
unique: true,
})
@Check(
'CHK_plan_prices_kind',
`"kind" IN ('fixed_recurring', 'per_seat_recurring')`,
)
@Check(
'CHK_plan_prices_amount_minor_nonnegative',
`"amount_minor" >= 0`,
)
export class PlanPriceEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({
type: 'uuid',
name: 'plan_version_id',
})
planVersionId!: string;
@ManyToOne(() => PlanVersionEntity, (plan) => plan.prices, {
onDelete: 'RESTRICT',
})
@JoinColumn({
name: 'plan_version_id',
foreignKeyConstraintName: 'FK_plan_prices_plan_version',
})
planVersion!: PlanVersionEntity;
@Column({
type: 'varchar',
length: 32,
})
kind!: PriceKind;
// PostgreSQL bigint is represented as string at the TypeScript boundary.
@Column({
type: 'bigint',
name: 'amount_minor',
})
amountMinor!: string;
@CreateDateColumn({
type: 'timestamptz',
name: 'created_at',
})
createdAt!: Date;
}
@Entity('plan_feature_entitlements')
@Index(
'UQ_plan_feature_entitlements_version_key',
['planVersionId', 'featureKey'],
{ unique: true },
)
export class FeatureEntitlementEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({
type: 'uuid',
name: 'plan_version_id',
})
planVersionId!: string;
@ManyToOne(() => PlanVersionEntity, (plan) => plan.entitlements, {
onDelete: 'RESTRICT',
})
@JoinColumn({
name: 'plan_version_id',
foreignKeyConstraintName:
'FK_plan_feature_entitlements_plan_version',
})
planVersion!: PlanVersionEntity;
// A stable machine-readable key, not a display label.
@Column({
type: 'varchar',
length: 120,
name: 'feature_key',
})
featureKey!: string;
// Supports boolean features and controlled quota or configuration values.
@Column({
type: 'jsonb',
})
value!: EntitlementValue;
@CreateDateColumn({
type: 'timestamptz',
name: 'created_at',
})
createdAt!: Date;
}
The unique constraint on (plan_version_id, kind) enforces the “at most one fixed component and one per-seat component” rule. The unique constraint on (plan_version_id, feature_key) prevents duplicate definitions of a feature in one plan snapshot.
The foreign keys use RESTRICT, not CASCADE. Deleting a plan version or price history because someone removes a catalog record would be a serious billing-data failure. Retire offerings; do not delete published commercial records.
Use TypeORM’s decorator reference as a focused reference for the mapping choices in the entities above: ownership relations, composite unique constraints, indexes, and database checks.
In “Relation decorators,” read from “Many-to-one” through “JoinColumn.” Follow the relation mapping, paying particular attention to which table owns the foreign key. Then, in “Other decorators,” read the Index, Unique, and Check subsections. Review composite indexes and check constraints as database-level protection against invalid catalog rows.
Why use jsonb for entitlement values?
Entitlement values vary by feature:
featureKey | value | Meaning |
|---|---|---|
analytics.export | true | Export is enabled |
projects.max | 25 | Owner may create up to 25 projects |
support.level | "priority" | Priority support tier |
retention.policy | {"days": 365} | Structured policy configuration |
Keep the keys stable and machine-readable. Use display metadata elsewhere if the admin UI needs labels, descriptions, or translations.
Do not write application logic such as:
if (subscription.planCode === 'pro') {
allowExport();
}
Instead, later entitlement evaluation will ask whether the current subscription grants analytics.export. That makes a newly versioned plan a catalog change rather than a scattered code deployment.
6. Publish and retire through a catalog service
Decorators describe persistence, but they cannot by themselves enforce the aggregate-level rules:
- A plan must have at least one price component before publication.
- A plan may have only one fixed component and one per-seat component.
- Feature keys may not repeat.
- Published commercial fields must not change.
- Retirement is allowed only after publication.
Make catalog writes command-driven. An administration service should expose operations with names such as:
createDraftPlanVersionaddPriceToDraftsetDraftEntitlementpublishDraftretireOfferingcreateNextVersionFrom
The critical validation belongs in publishDraft, inside a database transaction:
async publishDraft(
planVersionId: string,
publishedAt = new Date(),
): Promise<PlanVersionEntity> {
return this.dataSource.transaction(async (manager) => {
const plan = await manager.findOneOrFail(PlanVersionEntity, {
where: { id: planVersionId },
relations: {
prices: true,
entitlements: true,
},
});
if (plan.publishedAt !== null) {
throw new DomainError(
'PLAN_VERSION_ALREADY_PUBLISHED',
);
}
if (plan.retiredAt !== null) {
throw new DomainError(
'CANNOT_PUBLISH_RETIRED_PLAN_VERSION',
);
}
const kinds = new Set(plan.prices.map((price) => price.kind));
if (plan.prices.length === 0) {
throw new DomainError(
'PLAN_VERSION_REQUIRES_A_PRICE',
);
}
if (kinds.size !== plan.prices.length) {
throw new DomainError(
'DUPLICATE_PRICE_KIND',
);
}
for (const price of plan.prices) {
if (!/^\d+$/.test(price.amountMinor)) {
throw new DomainError(
'PRICE_AMOUNT_MUST_BE_A_NONNEGATIVE_INTEGER',
{ priceId: price.id },
);
}
const featureKeys = new Set(
plan.entitlements.map((item) => item.featureKey),
);
if (featureKeys.size !== plan.entitlements.length) {
throw new DomainError(
'DUPLICATE_ENTITLEMENT_KEY',
);
}
plan.publishedAt = publishedAt;
return manager.save(plan);
});
}
The database constraints remain essential even though the service validates. They protect the system from:
- a future script that bypasses the service;
- an internal admin endpoint bug;
- concurrent writes;
- direct database maintenance mistakes.
Creating a new commercial version
When pricing or features change, create a new row:
| Plan code | Version | Fixed fee | Seat fee | New checkout availability |
|---|---|---|---|---|
pro | 1 | 4900 minor units | 800 minor units | retired |
pro | 2 | 5900 minor units | 1000 minor units | active |
The workflow is:
- Load the latest published version.
- Create a new draft with the same
planCodeandversion + 1. - Copy prices and entitlements into new child rows.
- Change the draft’s intended commercial terms.
- Validate and publish the new version.
- Retire the prior offering when it should no longer appear in checkout.
Existing subscriptions must continue to point to their already selected version. Do not recalculate an existing subscription by querying “the newest version with plan code pro.”
Catalog query for new checkout
A customer-facing catalog query should return only published and non-retired offerings:
SELECT *
FROM plan_versions
WHERE published_at IS NOT NULL
AND published_at <= now()
AND (retired_at IS NULL OR retired_at > now())
ORDER BY plan_code, version DESC;
In the application layer, load the associated prices and entitlements for display. Keep retired versions queryable internally for invoices, audit history, support investigation, and reconciliation.
7. Implementation checkpoint
Add these pieces to your NestJS project’s billing module:
PlanVersionEntity,PlanPriceEntity, andFeatureEntitlementEntity;- TypeScript unions for interval units and price kinds;
- a
bigint/ string convention foramountMinor; - composite unique indexes for plan versions, price kinds, and feature keys;
- checks for interval validity, nonnegative money, valid price kind, and retirement chronology;
- a draft-only catalog service that validates before publication;
- a repository method for listing published, non-retired offerings.
Keep plan version creation behind an internal administration boundary. Public customer APIs should read the catalog and select a plan version; they should never create or mutate plans.
Key takeaways
A plan is a versioned commercial contract, not a mutable tier label. Store a stable planCode plus an incrementing version, and make commercial fields, price components, and entitlements immutable after publication.
Model hybrid pricing with separate fixed_recurring and per_seat_recurring child rows. Store every monetary amount as a nonnegative bigint minor-unit value, represented as a string in TypeScript, and keep currency and billing cadence on the plan version.
Finally, retirement removes an offering from new checkout while preserving the exact historical plan definition required by existing subscriptions, invoices, and audits.
Next, you will define the subscription state machine: which states exist, which commands and provider events may transition between them, and which invariants must hold at every transition.
Can't find a good explanation? Sign up and we'll make it for you
Sign up