Create your own
Lesson illustration

Versioned TypeORM Billing Plans and Prices for Recurring, Per-Seat Fees and Entitlements

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:

ConceptExampleMutability
Plan codeproStable product-line identifier
Plan versionpro, version 3Immutable commercial definition after publication
Price componentfixed recurring fee; per-seat recurring feeImmutable child of a plan version
Entitlementanalytics.export = trueImmutable 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 publishedAt is 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 componentAmountQuantityMonthly subtotal
Fixed recurring$49.001$49.00
Per-seat recurring$8.0012 seats$96.00
Total before tax$145.00
A subscription selects a product price and supplies a quantity. In a per-seat model, the recurring charge is the per-user amount multiplied by the purchased seat quantity; the module will additionally support a fixed recurring component on the same plan version.

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 amountCurrencyStored amountMinor
$49.00USD"4900"
$8.00USD"800"
¥1,200JPY"1200"
12.345 dinarsKWD"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.

Decorator reference

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:

featureKeyvalueMeaning
analytics.exporttrueExport is enabled
projects.max25Owner 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:

  1. A plan must have at least one price component before publication.
  2. A plan may have only one fixed component and one per-seat component.
  3. Feature keys may not repeat.
  4. Published commercial fields must not change.
  5. Retirement is allowed only after publication.

Make catalog writes command-driven. An administration service should expose operations with names such as:

  • createDraftPlanVersion
  • addPriceToDraft
  • setDraftEntitlement
  • publishDraft
  • retireOffering
  • createNextVersionFrom

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 codeVersionFixed feeSeat feeNew checkout availability
pro14900 minor units800 minor unitsretired
pro25900 minor units1000 minor unitsactive

The workflow is:

  1. Load the latest published version.
  2. Create a new draft with the same planCode and version + 1.
  3. Copy prices and entitlements into new child rows.
  4. Change the draft’s intended commercial terms.
  5. Validate and publish the new version.
  6. 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, and FeatureEntitlementEntity;
  • TypeScript unions for interval units and price kinds;
  • a bigint / string convention for amountMinor;
  • 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