Good to see the subscription lifecycle taking shape. In the previous lesson, you treated configuration changes conservatively: immediate upgrades require provider confirmation, while scheduled reductions preserve the currently effective plan and seat quantity until the billing boundary.
Cancellation follows the same principle, but with a sharper access consequence. A request to stop renewal is not necessarily a request to remove access now. In this lesson, you will implement both meanings explicitly:
- End-of-period cancellation: stop the next renewal but retain access through the paid period.
- Immediate cancellation: end provider billing and local access once the provider confirms termination.
- Reversal: remove a pending end-of-period cancellation before it takes effect, when the provider supports it.
The goal is a provider-neutral NestJS design that remains correct through retries, asynchronous confirmation, and organization-level authorization.
Treat cancellation timing as part of the command
Avoid a single ambiguous cancelSubscription() method. “Cancel” can mean either “do not renew” or “remove access now,” and those have fundamentally different business effects.
Model the requested timing directly:
export type CancellationTiming = 'end_of_period' | 'immediate';
export interface RequestSubscriptionCancellation {
subscriptionId: string;
timing: CancellationTiming;
idempotencyKey: string;
/**
* Customer-provided feedback only. Do not put internal support notes here.
*/
customerReasonCode?: string;
customerComment?: string;
/**
* Required by your product policy for immediate self-service cancellation.
* Useful for preventing an accidental destructive action.
*/
immediateAccessLossConfirmed?: boolean;
}
For an organization-owned subscription, authorization is not “is this user subscribed?” It is: does this actor have billing-management authority for this billable owner? Continue using the host integration boundary established in Module 1, so the same command works for both personal and organization subscriptions.
Immediate cancellation should usually be more restricted than end-of-period cancellation:
| Intent | Typical actor | Access after provider confirmation | Reversible? |
|---|---|---|---|
| End of period | Customer billing admin or support | Remains active until current period ends | Yes, before expiry if supported |
| Immediate | Customer who explicitly confirms, internal admin, fraud or policy workflow | Ends immediately | No; create a new subscription instead |
| Immediate with refund | Usually internal support/admin | Ends immediately | Depends on refund policy, but access termination remains final |
A refund is not an implied consequence of immediate cancellation. Some providers offer none, full, or partial refunds; others make refunds a separate API operation. Your product policy must decide whether a requester can select a refund outcome, and the provider adapter must state whether it can honor it.
Separate a pending cancellation from a canceled subscription
An end-of-period cancellation is best represented as an overlay on an active subscription, not as an immediate transition to canceled.
A subscription scheduled to end still has valid paid access. Its plan features and purchased seats remain effective until the provider’s authoritative period-end timestamp. The key fields are:
export interface SubscriptionLifecycleFields {
status: 'trialing' | 'active' | 'past_due' | 'canceled';
currentPeriodStart: Date | null;
currentPeriodEnd: Date | null;
cancelAtPeriodEnd: boolean;
scheduledCancellationAt: Date | null;
canceledAt: Date | null;
endedAt: Date | null;
}
The invariant is:
only while the subscription remains active enough to retain access and has a known future end time.
A minimal database constraint protects against contradictory local data:
ALTER TABLE subscriptions
ADD COLUMN cancel_at_period_end boolean NOT NULL DEFAULT false,
ADD COLUMN scheduled_cancellation_at timestamptz NULL,
ADD COLUMN canceled_at timestamptz NULL,
ADD COLUMN ended_at timestamptz NULL;
ALTER TABLE subscriptions
ADD CONSTRAINT subscriptions_scheduled_cancellation_consistency
CHECK (
(cancel_at_period_end = false AND scheduled_cancellation_at IS NULL)
OR
(cancel_at_period_end = true AND scheduled_cancellation_at IS NOT NULL)
);
The state distinction is small but important:
PendingEnd is not necessarily a new stored status enum value. In this design, it is the combination:
status === 'active' && cancelAtPeriodEnd === true
That keeps entitlement rules straightforward later: the subscription is still active, but it is visibly non-renewing.
See the provider behavior, but do not copy its API into your domain
The terminology varies by provider. Polar calls end-of-period cancellation “canceling” and immediate termination “revoking”; Recurly uses “cancellation” and “termination.” Your provider-neutral domain should use the unambiguous terms end of period and immediate.
Managing subscriptions - Polar
Read Polar's “Managing subscriptions” documentation to see a concrete provider distinguish a scheduled cancellation from immediate revocation, and to see that pending cancellation can be reversed before the end date.
In “Cancel or revoke,” read the “Cancel at period end” subsection from the scheduled-cancellation behavior. Focus on the fact that benefits remain active through the paid period. Then read “Revoke immediately” and “Uncancel,” especially the irreversible immediate case and the reversal conditions.
One provider-specific detail is particularly useful as a product boundary: Polar makes immediate revocation irreversible and handles refunds separately. Your adapter should normalize that behavior without assuming every provider behaves exactly the same way.
Read the relevant Recurly guide sections for a contrasting provider vocabulary. It illustrates that immediate termination may include an explicit refund policy, whereas scheduled cancellation waits for a term or billing boundary.
In the “Expiring” section, read the cancellation versus termination distinction. Continue through “Termination,” noting that a termination can specify refund handling and that it is final. Then read the end of “Cancellation,” where reactivation before expiration is described.
The takeaway is not to reproduce either provider’s endpoints. It is to make cancellation timing, reversal support, and refund behavior explicit in your own contract.
Extend the billing-provider contract
Your application should ask the adapter for a business-level cancellation operation, not issue provider-specific DELETE or PATCH requests from a controller.
export interface BillingProviderCapabilities {
cancelAtPeriodEnd: boolean;
immediateCancellation: boolean;
reverseScheduledCancellation: boolean;
/**
* Whether the provider can execute a refund as part of immediate
* cancellation. Keep false if refunds require a distinct workflow.
*/
immediateCancellationRefundModes: Array<'none' | 'full' | 'partial'>;
}
export interface ProviderCancellationRequest {
providerSubscriptionId: string;
timing: CancellationTiming;
expectedProviderVersion: string | null;
idempotencyKey: string;
customerReasonCode?: string;
customerComment?: string;
refundMode?: 'none' | 'full' | 'partial';
}
export interface ProviderReverseCancellationRequest {
providerSubscriptionId: string;
expectedProviderVersion: string | null;
idempotencyKey: string;
}
export interface ProviderSubscriptionSnapshot {
providerSubscriptionId: string;
version: string | null;
status: 'trialing' | 'active' | 'past_due' | 'canceled';
currentPeriodStart: Date | null;
currentPeriodEnd: Date | null;
cancelAtPeriodEnd: boolean;
scheduledCancellationAt: Date | null;
canceledAt: Date | null;
endedAt: Date | null;
}
export type ProviderCancellationResult =
| {
kind: 'applied';
snapshot: ProviderSubscriptionSnapshot;
}
| {
kind: 'awaiting_confirmation';
providerOperationReference: string | null;
}
| {
kind: 'unsupported';
capability:
| 'cancel_at_period_end'
| 'immediate_cancellation'
| 'reverse_scheduled_cancellation';
};
A few rules matter here:
- Use a stable provider idempotency key. It belongs to the persisted local operation, not to a one-off HTTP request.
- Send the expected provider version when supported. This avoids overwriting a provider-side change that happened after your last snapshot.
- Accept asynchronous completion. An accepted API response may not yet be an authoritative subscription state.
- Never infer state from request success alone. A provider can accept a command but later reject it, require customer action, or process it asynchronously.
If the provider lacks reverseScheduledCancellation, return a normalized provider_capability_unsupported error. Do not clear your local cancellation flag merely because the user asked to continue. The provider remains authoritative for future renewal.
Persist cancellation intent and its audit trail
You already have durable billing operations for externally visible commands. Use the same pattern for cancellation and reversal. Add a focused cancellation record so that your UI and auditors can distinguish an old completed cancellation from a current pending one.
CREATE TABLE subscription_cancellations (
id uuid PRIMARY KEY,
subscription_id uuid NOT NULL REFERENCES subscriptions(id),
timing varchar NOT NULL
CHECK (timing IN ('end_of_period', 'immediate')),
status varchar NOT NULL
CHECK (status IN (
'submitting',
'awaiting_provider_confirmation',
'scheduled',
'completed',
'reversed',
'failed'
)),
requested_by_actor_type varchar NOT NULL,
requested_by_actor_id varchar NOT NULL,
customer_reason_code varchar NULL,
customer_comment varchar NULL,
provider_operation_reference varchar NULL,
effective_at timestamptz NULL,
requested_at timestamptz NOT NULL DEFAULT now(),
confirmed_at timestamptz NULL,
reversed_at timestamptz NULL
);
CREATE UNIQUE INDEX one_open_cancellation_per_subscription
ON subscription_cancellations (subscription_id)
WHERE status IN (
'submitting',
'awaiting_provider_confirmation',
'scheduled'
);
Also continue to write a transition audit record for meaningful state changes:
| Audit event | When to write it |
|---|---|
cancellation_requested | A valid local operation is created |
cancellation_scheduled | Provider confirms end-of-period cancellation |
subscription_canceled_immediately | Provider confirms immediate termination |
cancellation_reversal_requested | A reversal operation is created |
cancellation_reversed | Provider confirms normal renewal is restored |
subscription_ended | Provider confirms expiry at the period boundary |
Store internal support notes in the internal audit record, not in customer_comment. Provider documentation often makes customer cancellation comments visible in a customer portal or purchase history.
Submit cancellation as a recoverable operation
The controller should validate its DTO, identify the authenticated actor, and call an application service. It must not hold a database transaction open while calling a remote billing provider.
The service follows the same durable-operation pattern used for checkout and subscription changes.
Create or reuse the local operation
async function requestCancellation(
command: RequestSubscriptionCancellation,
actor: AuthorizedActor,
): Promise<{ operationId: string }> {
return dataSource.transaction(async (manager) => {
const subscription = await subscriptions.lockRequired(
manager,
command.subscriptionId,
);
await billingAuthorizer.assertCanManageOwner(
actor,
subscription.ownerType,
subscription.ownerId,
);
assertCancellationAllowed(subscription, command);
if (
command.timing === 'immediate' &&
command.immediateAccessLossConfirmed !== true
) {
throw new DomainError(
'immediate_cancellation_confirmation_required',
);
}
const operation = await billingOperations.findOrCreate(manager, {
subscriptionId: subscription.id,
kind: 'subscription_cancellation',
idempotencyKey: command.idempotencyKey,
payload: {
timing: command.timing,
customerReasonCode: command.customerReasonCode ?? null,
customerComment: command.customerComment ?? null,
},
});
if (operation.wasCreated) {
await cancellationRepository.create(manager, {
id: operation.relatedEntityId,
subscriptionId: subscription.id,
timing: command.timing,
status: 'submitting',
actor,
customerReasonCode: command.customerReasonCode ?? null,
customerComment: command.customerComment ?? null,
});
await subscriptionAudits.append(manager, {
subscriptionId: subscription.id,
type: 'cancellation_requested',
actor,
details: { timing: command.timing },
});
}
return { operationId: operation.id };
});
}
Use a payload hash or equivalent validation inside findOrCreate. If the same idempotency key is reused with a different timing, reject it rather than treating “cancel now” and “cancel later” as equivalent requests.
Submit outside the transaction
async function submitCancellation(operationId: string): Promise<void> {
const operation = await billingOperations.getRequired(operationId);
if (operation.status === 'completed') {
return;
}
const subscription = await subscriptions.getRequired(
operation.subscriptionId,
);
const cancellation = await cancellationRepository.getRequired(
operation.relatedEntityId,
);
const capabilities = billingProvider.getCapabilities();
if (
cancellation.timing === 'end_of_period' &&
!capabilities.cancelAtPeriodEnd
) {
throw new ProviderCapabilityUnsupportedError(
'cancel_at_period_end',
);
}
if (
cancellation.timing === 'immediate' &&
!capabilities.immediateCancellation
) {
throw new ProviderCapabilityUnsupportedError(
'immediate_cancellation',
);
}
const result = await billingProvider.cancelSubscription({
providerSubscriptionId: subscription.providerSubscriptionId,
timing: cancellation.timing,
expectedProviderVersion: subscription.providerVersion,
idempotencyKey: operation.providerIdempotencyKey,
customerReasonCode: cancellation.customerReasonCode ?? undefined,
customerComment: cancellation.customerComment ?? undefined,
});
await cancellationResults.record(operation.id, result);
}
If the request times out, leave the operation recoverable. On retry, reload the same operation and reuse its saved providerIdempotencyKey. Never generate a new provider key for the retry.
Apply only confirmed provider state
A cancellation request changes local state only when an authoritative provider result or normalized provider event confirms it.
This is especially important for immediate cancellation. If your process crashes or the network fails after the provider receives the request, the local database may still show an active subscription until you retry or receive an event. That is preferable to prematurely revoking paid access based only on an uncertain request outcome.
Apply an end-of-period confirmation
When the provider snapshot confirms a scheduled end:
async function applyScheduledCancellation(
manager: EntityManager,
subscription: SubscriptionEntity,
cancellation: SubscriptionCancellationEntity,
snapshot: ProviderSubscriptionSnapshot,
): Promise<void> {
if (!snapshot.cancelAtPeriodEnd || !snapshot.scheduledCancellationAt) {
throw new DomainError('provider_snapshot_not_scheduled_for_cancellation');
}
subscription.cancelAtPeriodEnd = true;
subscription.scheduledCancellationAt =
snapshot.scheduledCancellationAt;
subscription.providerVersion = snapshot.version;
subscription.revision += 1;
cancellation.status = 'scheduled';
cancellation.effectiveAt = snapshot.scheduledCancellationAt;
cancellation.confirmedAt = new Date();
await subscriptions.save(manager, subscription);
await cancellationRepository.save(manager, cancellation);
await subscriptionAudits.append(manager, {
subscriptionId: subscription.id,
type: 'cancellation_scheduled',
details: {
effectiveAt: snapshot.scheduledCancellationAt.toISOString(),
},
});
}
Do not clear the current plan, reduce purchased seats, or revoke entitlements here. The subscription remains effective through scheduledCancellationAt.
At the actual boundary, a provider expiration event or authoritative snapshot will establish:
subscription.status = 'canceled';
subscription.cancelAtPeriodEnd = false;
subscription.scheduledCancellationAt = null;
subscription.endedAt = snapshot.endedAt;
The renewal-event lesson will make that boundary application idempotent and safe under duplicate deliveries.
Apply immediate cancellation
For immediate cancellation, require evidence that the provider actually ended the subscription:
async function applyImmediateCancellation(
manager: EntityManager,
subscription: SubscriptionEntity,
cancellation: SubscriptionCancellationEntity,
snapshot: ProviderSubscriptionSnapshot,
): Promise<void> {
if (snapshot.status !== 'canceled' || !snapshot.endedAt) {
throw new DomainError('provider_snapshot_not_immediately_canceled');
}
subscription.status = 'canceled';
subscription.cancelAtPeriodEnd = false;
subscription.scheduledCancellationAt = null;
subscription.canceledAt = snapshot.canceledAt ?? new Date();
subscription.endedAt = snapshot.endedAt;
subscription.providerVersion = snapshot.version;
subscription.revision += 1;
cancellation.status = 'completed';
cancellation.effectiveAt = snapshot.endedAt;
cancellation.confirmedAt = new Date();
await subscriptions.save(manager, subscription);
await cancellationRepository.save(manager, cancellation);
await subscriptionAudits.append(manager, {
subscriptionId: subscription.id,
type: 'subscription_canceled_immediately',
details: {
endedAt: snapshot.endedAt.toISOString(),
},
});
}
A subsequent entitlement evaluator should deny access because the subscription is now canceled. Seat assignments may remain historically recorded, but no longer create billable capacity or feature access.
Reverse a pending cancellation carefully
Reversal is valid only when all of these are true:
- The subscription is not already ended.
cancelAtPeriodEndis true locally.- The provider confirms that the subscription is still scheduled to end.
- The provider advertises
reverseScheduledCancellation. - The current time is before the authoritative scheduled end time.
Your command can remain intentionally narrow:
export interface ReversePendingCancellation {
subscriptionId: string;
idempotencyKey: string;
}
Create a new durable operation rather than mutating the original cancellation request in place. The original request is part of the audit trail.
async function reversePendingCancellation(
command: ReversePendingCancellation,
actor: AuthorizedActor,
): Promise<{ operationId: string }> {
return dataSource.transaction(async (manager) => {
const subscription = await subscriptions.lockRequired(
manager,
command.subscriptionId,
);
await billingAuthorizer.assertCanManageOwner(
actor,
subscription.ownerType,
subscription.ownerId,
);
if (
subscription.status === 'canceled' ||
!subscription.cancelAtPeriodEnd ||
!subscription.scheduledCancellationAt
) {
throw new DomainError('no_reversible_pending_cancellation');
}
if (subscription.scheduledCancellationAt <= new Date()) {
throw new DomainError('cancellation_reversal_window_closed');
}
return billingOperations.findOrCreate(manager, {
subscriptionId: subscription.id,
kind: 'reverse_subscription_cancellation',
idempotencyKey: command.idempotencyKey,
payload: {
expectedScheduledCancellationAt:
subscription.scheduledCancellationAt.toISOString(),
},
});
});
}
After the provider confirms a snapshot with cancelAtPeriodEnd: false, clear only the scheduling fields:
subscription.cancelAtPeriodEnd = false;
subscription.scheduledCancellationAt = null;
subscription.providerVersion = snapshot.version;
subscription.revision += 1;
Mark the cancellation record reversed, append cancellation_reversed to the audit history, and leave the subscription’s plan and current seat quantity unchanged.
An immediate cancellation is not reversed through this command. A customer who returns after immediate termination should go through a new checkout or subscription-creation flow. That preserves billing history and avoids pretending a previously ended provider subscription is still the same contractual object.
Define conflict rules with other pending changes
Your system already supports scheduled plan and seat reductions. A cancellation request may coexist with one of those changes, but only if your provider can represent both intentions correctly.
A practical policy is:
- Allow an end-of-period cancellation while a scheduled downgrade exists.
- Keep the scheduled downgrade as historical intent, but do not assume it will apply if the subscription expires first.
- After provider confirmation of cancellation, mark a local scheduled change as
blocked_by_pending_cancellationif its effective time is the same as, or later than, the scheduled end. - On reversal, do not silently restore a previously blocked provider-side change. Require a fresh quote if the provider no longer guarantees the original schedule.
This avoids a misleading situation where your UI promises “Starter at renewal” even though there will be no renewal.
Also reject a cancellation or reversal command when another cancellation-related operation is currently submitting or awaiting confirmation. A clear subscription_operation_in_progress response is preferable to issuing competing provider mutations.
Expose a small, predictable API surface
Your Angular self-service UI will need a summary that makes the difference visible:
export interface SubscriptionSummaryResponse {
status: string;
currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean;
scheduledCancellationAt: string | null;
canReverseCancellation: boolean;
canCancelAtPeriodEnd: boolean;
canCancelImmediately: boolean;
}
The relevant endpoints can remain simple:
POST /subscriptions/:id/cancellation
POST /subscriptions/:id/cancellation/reversal
GET /subscriptions/:id
GET /subscriptions/:id/audit
For a pending cancellation, the UI should say something equivalent to:
Your subscription remains active until 2026-06-30. It will not renew automatically.
Do not label it merely “Canceled.” That word suggests access is already gone.
For immediate cancellation, require a clear confirmation screen that states access will end after provider confirmation and explains the refund policy, if any. The client should send only the requested timing, optional customer feedback, and an idempotency key. It should never send provider identifiers, period-end dates, or an access state to be trusted.
Verify behavior with focused integration tests
Use the deterministic fake provider from Module 2 and PostgreSQL-backed tests. Each test should verify both local state and provider-call behavior.
| Scenario | Expected result |
|---|---|
| Active subscription requests end-of-period cancellation | Provider receives one request; after confirmed snapshot, subscription remains active with cancelAtPeriodEnd = true. |
| Pending cancellation reaches the period boundary | Provider event changes status to canceled exactly once; endedAt is persisted. |
| Customer reverses before the scheduled end | Provider receives reversal request; confirmed snapshot clears cancellation fields and allows renewal. |
| Customer reverses after expiry | Reject with no_reversible_pending_cancellation; do not call provider. |
| Immediate cancellation is confirmed | Subscription becomes canceled; entitlement access is no longer granted. |
| Immediate cancellation request times out | Preserve local active state; retry the same operation with the same provider idempotency key. |
| Provider lacks reversal capability | Return provider_capability_unsupported; preserve the pending cancellation. |
| Same cancellation command is submitted twice | One durable operation and one provider-side cancellation request are produced. |
| Same idempotency key is reused with different timing | Reject as an idempotency conflict. |
| Scheduled cancellation plus scheduled downgrade | Summary shows cancellation as the governing future outcome; downgrade is not presented as guaranteed. |
A particularly valuable assertion is this: a successfully scheduled end-of-period cancellation must not cause the entitlement evaluator to deny access before scheduledCancellationAt.
Key takeaways
A cancellation implementation is reliable when it preserves the difference between ending renewal and ending access:
- End-of-period cancellation leaves the subscription active and its benefits valid through the authoritative period end.
- Immediate cancellation changes access only after the provider confirms termination.
- Pending cancellation is reversible only before expiration and only when the provider supports reversal.
- Treat cancellation and reversal as durable, idempotent external operations.
- Persist provider-confirmed lifecycle fields separately from customer intent and audit history.
- Keep refund policy explicit; immediate cancellation does not automatically imply a refund.
- Do not let a local request or UI action override the provider’s authoritative subscription state.
Next, you will handle renewal events so that a successful renewal advances the local billing period exactly once, even when events are duplicated or delivered asynchronously.
Can't find a good explanation? Sign up and we'll make it for you
Sign up