Create your own
Lesson illustration

Implement Subscription Changes with Quotes, Scheduling, and Seat Validation

Welcome back. In the previous lesson, you separated trial access from paid access and established a conservative rule: a payment attempt or checkout return is not proof that paid access should be granted. That same conservatism matters when a customer changes an existing subscription. A quoted upgrade is not yet an upgrade; a requested downgrade is not yet a lower seat limit.

This lesson implements a provider-neutral subscription-change workflow for fixed recurring plans plus per-seat pricing. You will distinguish changes that should take effect now from changes that should wait for the next billing period, obtain and confirm provider quotes, preserve the current configuration until confirmation, and refuse seat reductions that would invalidate current assignments.


Treat a subscription change as a request for a new configuration

A subscription has a current effective configuration: the plan version and the purchased seat quantity that presently determine entitlement and capacity.

For this course, model the relevant configuration explicitly:

export interface SubscriptionConfiguration {
  planVersionId: string;
  seatQuantity: number;
}

A change request asks for a target configuration:

export interface RequestSubscriptionChange {
  subscriptionId: string;
  targetPlanVersionId: string;
  targetSeatQuantity: number;
  idempotencyKey: string;
}

The command should never decide that a change is an upgrade by comparing prices. A discounted annual plan may cost less per month while granting more capability. Similarly, a higher fixed fee does not necessarily imply a richer entitlement set.

Instead, make the catalog define a deliberate plan relationship within a product family:

export type PlanDirection =
  | 'upgrade'
  | 'downgrade'
  | 'same'
  | 'incomparable';

export interface PlanChangePolicy {
  compare(
    currentPlanVersionId: string,
    targetPlanVersionId: string,
  ): PlanDirection;
}

This can be backed by an immutable editionRank, or by an explicit directed relationship such as “Starter may upgrade to Pro.” The important point is that product policy, not arithmetic on price, defines whether a plan change adds or removes capability.

For a first production implementation, use these rules:

Requested changeClassificationEffective time
Higher plan, same seat countUpgradeImmediately, after provider confirmation
Same plan, more seatsCapacity increaseImmediately, after provider confirmation
Lower plan, same seat countDowngradeNext billing-period boundary
Same plan, fewer seatsCapacity decreaseNext billing-period boundary
Same plan and seat countNo-opReject or return current summary
Upgrade plus fewer seats, or downgrade plus more seatsMixedReject and require separate commands

The last rule is intentionally conservative. A mixed request has two different effective times: for example, a customer may deserve an upgraded feature immediately but retain their current seats until renewal. Some providers support subscription phases or multiple pending changes; others support only one scheduled mutation. Do not create accidental semantics by pretending every provider can represent this cleanly.

For now, return a machine-readable error such as:

export class MixedChangeRequiresSeparateCommandsError extends Error {
  readonly code = 'mixed_change_requires_separate_commands';
}

This keeps the command understandable for both the customer UI and an internal admin UI.


“Immediate” means effective now, not “change local rows now”

An immediate upgrade or seat increase normally involves a prorated charge for the remainder of the billing period. The customer must see the actual provider-calculated amount, tax, credits, and next recurring amount before they confirm.

The provider, rather than your application, should calculate this quote. Your local plan price is useful for catalog display, but it cannot reliably account for tax location, active discounts, account credit, current period boundaries, or provider-specific rounding.

Seat-Based Billing - Dodo Payments Documentation

Read the “Proration for Seat Changes” section from Dodo Payments. It is provider-specific, but it usefully demonstrates why a seat change needs an explicit billing mode and why the billing-cycle effect must be visible in a quote.

In the “Proration for Seat Changes” section, read the proration modes and preview guidance. Focus on the distinction between a charge or credit today and the renewal-date effect. Do not adopt the provider’s mode names in your domain model: normalize their outcomes instead.

One provider may re-anchor the billing period after an immediate change; another may preserve the original renewal date. That detail affects invoices and customer messaging, so it belongs in the quote result. It should not be guessed from your own price calculation.

A provider-neutral quote model can look like this:

export type ChangeTiming = 'immediate' | 'next_period';

export interface Money {
  amountMinor: bigint;
  currency: string;
}

export interface SubscriptionChangeQuote {
  id: string;
  providerQuoteId: string | null;
  providerSubscriptionVersion: string | null;

  current: SubscriptionConfiguration;
  target: SubscriptionConfiguration;

  timing: ChangeTiming;
  effectiveAt: Date;

  immediateTotal: Money | null;
  nextRecurringTotal: Money;
  nextRecurringInterval: 'month' | 'year';

  expiresAt: Date | null;
  requiresCustomerConfirmation: boolean;
  providerSnapshot: unknown;
}

The quote carries two different financial statements:

  • immediateTotal is what the provider expects to collect or credit now. It may be zero for a scheduled reduction.
  • nextRecurringTotal is what a future normal renewal is expected to cost.

Store all provider monetary values in minor units, as you did for plan prices. Do not convert a decimal string to JavaScript number; use a string or bigint at the adapter boundary.

The timing is a product decision, but the adapter must confirm that it can actually honor it. A capability interface makes that explicit:

export interface BillingProviderCapabilities {
  quoteSubscriptionChanges: boolean;
  immediateSubscriptionChanges: boolean;
  scheduleChangesAtPeriodEnd: boolean;
  quotedChangeExecution: boolean;
}

export interface BillingProvider {
  getCapabilities(): BillingProviderCapabilities;

  quoteSubscriptionChange(
    request: ProviderQuoteChangeRequest,
  ): Promise<ProviderChangeQuote>;

  applyQuotedSubscriptionChange(
    request: ApplyQuotedChangeRequest,
  ): Promise<ProviderChangeResult>;
}

If the provider cannot schedule a downgrade at the end of the period, do not silently apply it immediately. Return provider_capability_unsupported, or use an explicitly designed internal scheduling strategy only if the provider can guarantee the same outcome.


Preview first; confirm the exact quoted change second

Many provider APIs expose a preview before an update. The exact HTTP details are provider-specific, but the lifecycle is broadly useful: fetch the current provider items, preview a complete desired item set, show the customer the result, then submit the confirmed change.

Add or remove items from a subscription

Read Paddle’s “Change quantities” guide as a concrete example of previewing a seat change before updating a subscription. The API names are Paddle-specific; the preview-and-confirm structure is the part to retain.

In the “Change quantities” section, read the API workflow through the preview explanation. Notice that the request identifies the complete intended recurring item set, and that the preview distinguishes immediate and future transactions. Stop before the large example response payload.

Your own API should expose this as two operations, even if the UI makes it feel like one workflow:

  1. Preview validates the target, asks the provider for a quote, and persists the returned quote snapshot.
  2. Confirm accepts a still-valid quote ID and a client idempotency key, then submits precisely that change to the provider.

This prevents a damaging UI pattern: previewing one amount, then making a later update that silently charges a different amount.

A useful local quote table has fields along these lines:

CREATE TABLE subscription_change_quotes (
  id uuid PRIMARY KEY,
  subscription_id uuid NOT NULL REFERENCES subscriptions(id),
  base_subscription_revision integer NOT NULL,

  target_plan_version_id uuid NOT NULL REFERENCES plan_versions(id),
  target_seat_quantity integer NOT NULL CHECK (target_seat_quantity > 0),
  timing varchar NOT NULL CHECK (timing IN ('immediate', 'next_period')),

  provider_name varchar NOT NULL,
  provider_quote_id varchar NULL,
  provider_subscription_version varchar NULL,
  provider_snapshot jsonb NOT NULL,

  immediate_amount_minor bigint NULL,
  next_recurring_amount_minor bigint NOT NULL,
  currency char(3) NOT NULL,
  effective_at timestamptz NOT NULL,
  expires_at timestamptz NULL,

  status varchar NOT NULL CHECK (status IN ('quoted', 'confirmed', 'expired', 'superseded')),
  created_at timestamptz NOT NULL DEFAULT now()
);

base_subscription_revision is important. Increment a subscription’s local revision whenever its effective configuration or pending scheduled change changes. At confirmation time, a quote based on revision must not mutate a subscription now at revision .

A quote is also stale if:

  • it has passed expiresAt;
  • its provider subscription version is older than the current known provider version;
  • another quote has already been confirmed for a conflicting scheduled change;
  • the customer’s desired target differs from the stored target.

In all of those cases, create a new quote and require confirmation again. Never substitute a new provider quote under an existing customer confirmation.


Keep current configuration and scheduled configuration separate

The central persistence rule is simple:

A scheduled downgrade describes the future. It must not overwrite the configuration that is effective today.

Suppose an organization is on Pro with 12 seats, paid through the end of the month. They request Starter with 8 seats. Until the boundary arrives, their current plan remains Pro and their current purchased seat quantity remains 12. The system should display a pending change, not rewrite the subscription as Starter with 8 seats immediately.

A subscription-update decision flow distinguishing add-on, seat-quantity, and billing-period changes. It illustrates that reductions can be routed to an end-of-period scheduled update while additions are normally applied immediately, depending on configured product policy.

Persist the future intention independently:

export type SubscriptionChangeStatus =
  | 'quoted'
  | 'submitting'
  | 'awaiting_payment'
  | 'scheduled'
  | 'applied'
  | 'failed'
  | 'superseded';

export interface SubscriptionChangeEntity {
  id: string;
  subscriptionId: string;
  quoteId: string;
  status: SubscriptionChangeStatus;

  targetPlanVersionId: string;
  targetSeatQuantity: number;
  timing: ChangeTiming;
  effectiveAt: Date;

  providerChangeReference: string | null;
  providerOperationReference: string | null;
  idempotencyKey: string | null;
}

At minimum, enforce one active scheduled change per subscription:

CREATE UNIQUE INDEX one_scheduled_change_per_subscription
  ON subscription_changes (subscription_id)
  WHERE status = 'scheduled';

This is not merely a database tidiness rule. If an organization requests a reduction to 8 seats and then requests a different reduction to 10 seats, the second request must deliberately replace or supersede the first. It must not leave two contradictory future configurations.

For a scheduled change, record:

  • the requested target plan and quantity;
  • the provider-confirmed effective timestamp;
  • the provider’s schedule or change reference;
  • the immutable quote snapshot shown to the customer;
  • the actor and audit reason.

When the period-boundary event eventually arrives, a later lifecycle handler will apply the target configuration exactly once. For now, the key rule is that the current configuration remains authoritative for access and current seat capacity until then.


Reject a seat decrease below active assignments

Purchased seats are a billing quantity. Assigned seats represent people or members currently consuming that capacity. These are different counts.

For example, an organization may have:

ValueCount
Purchased seats12
Assigned seats9
Requested future quantity8

The requested quantity is invalid, even though the provider might accept the lower quantity. The billing system must not create a state in which nine members are assigned but the organization has paid for only eight seats.

Before creating either an immediate or scheduled quote, enforce:

Use a typed domain error that the API can expose safely:

export class SeatQuantityBelowAssignedError extends Error {
  readonly code = 'seat_quantity_below_assigned';

  constructor(
    readonly requestedQuantity: number,
    readonly assignedQuantity: number,
  ) {
    super(
      `Requested ${requestedQuantity} seats, but ${assignedQuantity} are assigned.`,
    );
  }
}

The check must run again at confirmation time. A customer can preview a reduction to 8 seats when 8 are assigned, then another administrator can assign a ninth seat before the customer confirms.

There is also a boundary problem: a reduction can be valid when scheduled but become invalid before it takes effect. Establish this rule now for the seat-assignment implementation in Module 5:

While a lower quantity is scheduled, new assignments must not raise the active assignment count above that scheduled target.

For example, with 9 assigned seats and a pending reduction to 10, the organization may still have 12 currently purchased seats. Nevertheless, do not allow assignment number 11, because the planned reduction could no longer be honored safely at renewal.

The simplest concurrency contract is for both the change command and the future seat-assignment command to lock the same subscription row before examining or changing capacity-related state. The seat lesson will implement that transaction fully; this lesson establishes why the shared lock is necessary.


Implement the preview and confirmation services

Keep the controller thin. Authorization should use the host integration boundary from Module 1: the actor must have billing-management authority for the billable owner, whether that owner is an individual account or an organization.

1. Preview the intended change

The preview service determines timing, validates the seat floor, maps your local configuration to provider price references, and saves the quote.

async function previewSubscriptionChange(
  command: RequestSubscriptionChange,
): Promise<SubscriptionChangeQuote> {
  await billingAuthorizer.assertCanManageSubscription(
    command.subscriptionId,
  );

  const subscription = await subscriptions.getRequired(command.subscriptionId);
  assertChangeableSubscriptionStatus(subscription.status);

  const current: SubscriptionConfiguration = {
    planVersionId: subscription.planVersionId,
    seatQuantity: subscription.seatQuantity,
  };

  const target: SubscriptionConfiguration = {
    planVersionId: command.targetPlanVersionId,
    seatQuantity: command.targetSeatQuantity,
  };

  const assignedCount = await seatAssignments.countActiveForOwner(
    subscription.ownerType,
    subscription.ownerId,
  );

  if (target.seatQuantity < assignedCount) {
    throw new SeatQuantityBelowAssignedError(
      target.seatQuantity,
      assignedCount,
    );
  }

  const timing = changePolicy.classify(current, target);

  if (timing === 'no_change') {
    throw new NoSubscriptionChangeError();
  }

  const providerRequest = await providerMapper.toQuoteRequest({
    subscription,
    target,
    timing,
  });

  const providerQuote = await billingProvider.quoteSubscriptionChange(
    providerRequest,
  );

  return subscriptionQuotes.create({
    subscriptionId: subscription.id,
    baseSubscriptionRevision: subscription.revision,
    current,
    target,
    timing,
    providerQuote,
  });
}

changePolicy.classify should return an error for incomparable plans and mixed changes. It should return immediate only for an upgrade or quantity increase, and next_period only for a downgrade or quantity decrease.

The quote adapter translates local plan versions into the provider’s recurring item model. That mapping is why each immutable plan version needs stable provider price references rather than a dynamically reconstructed price.

2. Confirm a specific quote using a durable operation

Confirmation is an external-side-effect command. Follow the recoverable operation pattern from the provider-boundary module:

  1. Start a database transaction.
  2. Lock the subscription row.
  3. Load and validate the local quote.
  4. Recheck the assigned-seat floor.
  5. Create or reuse a local billing operation using the client idempotency key.
  6. Commit the transaction.
  7. Call the provider outside the database transaction.
  8. Persist the provider result or wait for a normalized provider event.
  9. Apply the provider-confirmed state transactionally.
export interface ConfirmSubscriptionChange {
  quoteId: string;
  idempotencyKey: string;
}

async function confirmSubscriptionChange(
  command: ConfirmSubscriptionChange,
): Promise<SubscriptionChangeEntity> {
  const operation = await dataSource.transaction(async function createOperation(manager) {
    const quote = await subscriptionQuotes.lockRequired(manager, command.quoteId);
    const subscription = await subscriptions.lockRequired(manager, quote.subscriptionId);

    await billingAuthorizer.assertCanManageSubscription(subscription.id);

    assertQuoteUsable(quote, subscription);

    const assignedCount = await seatAssignments.countActiveForOwner(
      subscription.ownerType,
      subscription.ownerId,
      manager,
    );

    if (quote.targetSeatQuantity < assignedCount) {
      throw new SeatQuantityBelowAssignedError(
        quote.targetSeatQuantity,
        assignedCount,
      );
    }

    return billingOperations.findOrCreate(manager, {
      subscriptionId: subscription.id,
      kind: 'subscription_change',
      idempotencyKey: command.idempotencyKey,
      payload: {
        quoteId: quote.id,
        targetPlanVersionId: quote.targetPlanVersionId,
        targetSeatQuantity: quote.targetSeatQuantity,
        timing: quote.timing,
      },
    });
  });

  if (operation.status === 'completed') {
    return subscriptionChanges.getRequired(operation.subscriptionChangeId);
  }

  await changeSubmitter.submit(operation.id);

  return subscriptionChanges.getRequired(operation.subscriptionChangeId);
}

A suitable idempotency constraint is:

CREATE UNIQUE INDEX billing_operations_unique_client_command
  ON billing_operations (subscription_id, kind, idempotency_key);

If the network times out after the provider receives the request, retry with the same provider idempotency key saved on this operation. Do not create a second operation and do not generate a second provider key.

3. Submit without trusting the frontend

The submitting worker loads the operation and sends the quote identity, target, expected provider version, and stable idempotency key:

async function submitChangeOperation(operationId: string): Promise<void> {
  const operation = await billingOperations.getRequired(operationId);
  const quote = await subscriptionQuotes.getRequired(operation.payload.quoteId);
  const subscription = await subscriptions.getRequired(operation.subscriptionId);

  const result = await billingProvider.applyQuotedSubscriptionChange({
    providerSubscriptionId: subscription.providerSubscriptionId,
    providerQuoteId: quote.providerQuoteId,
    expectedProviderVersion: quote.providerSubscriptionVersion,
    target: {
      planVersionId: quote.targetPlanVersionId,
      seatQuantity: quote.targetSeatQuantity,
    },
    timing: quote.timing,
    idempotencyKey: operation.providerIdempotencyKey,
  });

  await providerChangeResults.record(operation.id, result);
}

The frontend sends only quoteId and its own idempotency key. It does not send price, tax, provider item IDs, timing, or a target copied from a stale screen. Those facts come from the persisted quote.


Apply confirmation differently for immediate and scheduled changes

Provider results have several distinct outcomes. Normalize them rather than leaking provider-specific statuses into the domain:

export type ProviderChangeResult =
  | {
      kind: 'applied';
      snapshot: ProviderSubscriptionSnapshot;
    }
  | {
      kind: 'scheduled';
      scheduledChangeReference: string;
      effectiveAt: Date;
      snapshot: ProviderSubscriptionSnapshot;
    }
  | {
      kind: 'requires_customer_action';
      actionUrl: string | null;
    }
  | {
      kind: 'payment_failed';
      reason: string;
    };

For an immediate change:

  • applied means the provider has authoritatively accepted the new configuration as effective now.
  • Update the local current plan and seat quantity in one transaction.
  • Increment the subscription revision.
  • Mark the change applied.
  • Append a transition or subscription-change audit record.

If payment is required or fails, preserve the old plan and old purchased quantity. The customer retains their already-paid access, but do not grant the incremental plan features or new seats until the provider confirms the change under your payment policy.

For a scheduled change:

  • Do not overwrite subscription.planVersionId or subscription.seatQuantity.
  • Save the provider schedule reference and authoritative effective time.
  • Mark the local change scheduled.
  • Display it in the customer subscription summary as a pending future configuration.

A provider response can be authoritative if the adapter guarantees it reflects the latest subscription snapshot. Otherwise, record the response, then await the normalized provider event or fetch an authoritative snapshot. Later webhook lessons will add signature verification, inbox persistence, ordering controls, and retry workers. Your domain method should already be safe to call more than once.


Test the policy as a set of observable outcomes

Use a fake provider that records quote and apply requests. Your tests should assert both domain state and external-call behavior.

ScenarioExpected outcome
Pro, 10 seats, request Pro with 15 seatsProvider receives an immediate quote request; local quantity becomes 15 only after confirmed application.
Starter, 10 seats, request Pro with 10 seatsProvider receives an immediate quote request; new plan is effective only after provider confirmation.
Pro, 10 seats, request Starter with 10 seatsProvider is asked for an end-of-period quote or schedule; current plan remains Pro; one pending change exists.
Pro, 10 seats, request Pro with 8 seats; 9 assignedReject with seat_quantity_below_assigned; no provider quote or mutation.
Pro, 10 seats, request Pro with 8 seats; 8 assignedCreate a scheduled reduction; current quantity remains 10.
Quote based on revision 4, subscription now at revision 5Reject confirmation as stale; require a new quote.
Same confirmation request delivered twiceOne billing operation and one provider idempotency key are used.
Immediate change requires payment actionPreserve the current configuration; record awaiting_payment; do not grant the new capacity.
Provider does not support period-end schedulingReturn provider_capability_unsupported; never apply the downgrade immediately.
Pending reduction to 8 seats, a new assignment would become number 9Reject the assignment once the seat-assignment workflow is implemented.

A practical implementation milestone is a test that proves that the provider receives zero calls for a seat reduction below active assignments. This protects both customer experience and billing correctness.


Key takeaways

A reliable subscription-change command rests on a few non-negotiable boundaries:

  • Classify plan direction from explicit product policy, never from price comparisons.
  • Use provider quotes for the actual immediate amount, recurring amount, tax, credits, renewal effect, and effective time.
  • Preview and persist a quote first; confirm exactly that still-valid quote later.
  • Treat an immediate change as pending until the provider confirms it, especially when payment or customer action is involved.
  • Keep current configuration separate from a scheduled end-of-period downgrade.
  • Reject any requested seat quantity below active assignments, and recheck at confirmation time.
  • Use a durable local operation plus stable provider idempotency key so retries cannot duplicate a billing change.

Next, you will implement cancellation behavior: immediate cancellation, end-of-period cancellation, and reversal of a pending cancellation when the provider supports it.

Can't find a good explanation? Sign up and we'll make it for you

Sign up