Welcome. This lesson begins the Trials and Subscription Lifecycle module. You will implement the part of a subscription system that is deceptively easy to get wrong: letting someone use a plan for free during a trial, then granting paid access only when the first bill is actually settled.
The earlier domain model and provider boundary give us the foundation: a subscription belongs to a generic billable owner, refers to an immutable plan version, and has a provider-specific counterpart. Here we will add lifecycle rules that work whether the owner is an individual account or an organization, and whether the provider is Stripe-like, invoice-based, or another billing system.
By the end, you should have a clear implementation design for trial activation, time-based expiry, cardless trials, and verified conversion to paid access.
Trial access and paid access are different promises
A trial is an access entitlement with a deadline. A paid subscription is an entitlement backed by a confirmed billing outcome. Treating both as merely “active subscriptions” leads to accidental access grants.
A common failure sequence looks like this:
- The customer completes a checkout redirect or adds a card.
- The frontend reports apparent success.
- The application changes the local subscription to
active. - The provider later reports that authentication was required, payment failed, or no payment method existed.
The customer now has paid access that your system cannot justify.
The central invariant for this lesson is:
A payment method, checkout return, invoice creation, or payment attempt must never activate paid access. Only confirmed settlement for the subscription’s first paid period may do so.
This does not mean trials should be unavailable until money is collected. During the trial, access is valid because the plan explicitly grants a free period. Once the trial deadline passes, that basis disappears.
Use separate concepts in the model:
| Concept | Meaning | Grants product access? |
|---|---|---|
trialing | Provider-confirmed free period is in progress. | Yes, until trialEndsAt. |
payment_pending | Trial has ended; a first paid invoice or payment is awaiting settlement. | No. |
active | First paid billing period has been confirmed as settled. | Yes. |
paused or equivalent | Provider has stopped the subscription, often because no payment method was supplied. | No. |
canceled | Subscription has ended permanently. | No. |
The precise names can differ from a provider’s native statuses. What matters is that your local normalized state has unambiguous entitlement meaning.
Design the trial state machine around evidence
A compact local state machine is enough for this outcome. Your full state machine can contain additional states for cancellation and dunning later, but do not blur their meaning into the trial flow.
There is one slightly unusual but useful transition: trialing may become active if the provider confirms a paid period at the same moment that the trial ends. In practice, an expiry worker and a payment event may race. The transition rules must allow the verified settlement event to win without requiring an intermediate payment_pending write.
The minimum subscription fields
Assuming your first module already has a Subscription entity, the trial lifecycle needs fields similar to these:
export enum SubscriptionStatus {
TRIAL_ACTIVATION_PENDING = 'trial_activation_pending',
TRIALING = 'trialing',
PAYMENT_PENDING = 'payment_pending',
ACTIVE = 'active',
PAUSED = 'paused',
CANCELED = 'canceled',
}
@Entity('subscriptions')
export class SubscriptionEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'enum', enum: SubscriptionStatus })
status!: SubscriptionStatus;
@Column({ type: 'timestamptz', nullable: true })
trialStartedAt!: Date | null;
@Column({ type: 'timestamptz', nullable: true })
trialEndsAt!: Date | null;
@Column({ type: 'timestamptz', nullable: true })
paidAccessConfirmedAt!: Date | null;
@Column({ type: 'varchar', nullable: true })
providerSubscriptionId!: string | null;
@Column({ type: 'uuid' })
planVersionId!: string;
}
A corresponding BillingPeriod record should identify its provider invoice or equivalent provider billing reference. That lets you show the current period and prevents one settled invoice from being applied more than once.
A practical schema-level safeguard is a provider-scoped unique constraint such as:
UNIQUE (provider_name, provider_invoice_id)
A settled invoice is financial evidence, not just a message. Persisting it separately gives you an auditable answer to: Why did this subscription become paid?
Invariants worth enforcing in code
Keep these rules close to the domain service, not scattered across controllers and UI code:
- A
trialingsubscription must have bothtrialStartedAtandtrialEndsAt. trialEndsAtmust be later thantrialStartedAt.activerequirespaidAccessConfirmedAt.activerequires a settled first paid billing period belonging to this subscription.- A subscription can use trial access only while the database clock is earlier than
trialEndsAt. payment_pending,paused, andcancelednever grant paid access.- An attached payment method is useful customer data, but is not payment confirmation.
For eligibility, define a deliberate policy. A reusable default is “one trial per billable owner per trial group,” where a trial group is a product-defined value such as core-platform. That stops a customer from repeatedly obtaining trials merely by moving among monthly and annual variants of the same offering.
Record consumption independently from the current subscription:
CREATE TABLE trial_consumptions (
id uuid PRIMARY KEY,
owner_type varchar NOT NULL,
owner_id varchar NOT NULL,
trial_group varchar NOT NULL,
subscription_id uuid NOT NULL REFERENCES subscriptions(id),
consumed_at timestamptz NOT NULL,
UNIQUE (owner_type, owner_id, trial_group)
);
That unique constraint is stronger than a “check then insert” approach under concurrent requests.
Cardless trials: collect payment details without promising activation
A cardless trial lets a customer start using the product without a payment method. Your customer portal should explain that the trial is active now, payment details can be added before the deadline, and adding them does not itself make the account paid.

In the illustrated flow, “Add payment method” begins a provider-hosted payment-method collection session. After the user returns, your UI should refresh subscription state from your backend, but it should still show Trial until verified conversion occurs.
This behavior reflects an important provider-neutral distinction:
- Payment method present: the provider may be able to charge later.
- Payment initiated: a charge attempt or invoice exists.
- Payment settled: the provider has confirmed the particular post-trial obligation was paid.
- Paid access activated: your domain has accepted that evidence and recorded the transition.
Stripe’s documentation provides a useful concrete example of the cardless-trial choice, even though your module should retain its provider-neutral interface.
Billing collection methods | Stripe Documentation
Read this Stripe documentation as a concrete reference for two policies your generic module must support: collecting a payment method during a free trial, and treating a first payment that still needs confirmation as incomplete rather than paid.
In “Set a collection method for a subscription,” read the subsection “Automatic charge for free trials without payment method.” Follow the cardless-trial pathway, noting the distinct choices available when a trial ends without payment details. Then, in “Collection methods and failed payments” under “Failed subscription payments,” read the full subsection. Start at the incomplete-payment setup and continue through the explanation of when the subscription becomes active or expires. Focus on the semantic distinction between a created subscription and a settled first invoice.
Your generic plan or trial policy might expose these choices without encoding provider-specific terms:
export type TrialEndPolicy =
| 'collect_automatically_if_payment_method_exists'
| 'pause_until_payment_method'
| 'cancel_if_payment_method_missing'
| 'issue_manual_invoice';
export interface TrialPolicy {
durationDays: number;
trialGroup: string;
paymentMethodRequiredAtStart: boolean;
endPolicy: TrialEndPolicy;
}
For a software product with a self-service UI, collect_automatically_if_payment_method_exists plus pause_until_payment_method is often a sensible cardless-trial design. It avoids an accidental charge without a payment method while preserving a clear recovery path.
Activate a trial through a recoverable command
Trial activation should use the same recoverable-operation pattern as checkout. Do not hold a PostgreSQL transaction open while making a provider API call.
A robust sequence is:
- Validate the authenticated actor can manage billing for the billable owner.
- Validate that the selected plan version is available and has a trial policy.
- Atomically reserve the trial-consumption record.
- Create a local subscription in
trial_activation_pending. - Persist a billing operation with a stable idempotency key.
- Ask the provider to create a subscription or schedule with the requested trial terms.
- On a confirmed provider response or authoritative provider event, record the provider subscription reference and transition to
trialing. - Set trial timestamps from the provider’s confirmed data where the provider controls billing time.
The important implementation detail is the ordering around step 3. You need the unique trial_consumptions record before the external call, otherwise two simultaneous requests can both decide that a trial is available.
Conceptually:
async startTrial(command: StartTrialCommand): Promise<SubscriptionEntity> {
const operation = await this.dataSource.transaction(async (manager) => {
await this.trialEligibility.reserve(
manager,
command.ownerType,
command.ownerId,
command.trialGroup,
);
const subscription = manager.create(SubscriptionEntity, {
status: SubscriptionStatus.TRIAL_ACTIVATION_PENDING,
planVersionId: command.planVersionId,
trialStartedAt: null,
trialEndsAt: null,
paidAccessConfirmedAt: null,
providerSubscriptionId: null,
});
await manager.save(subscription);
return this.operations.create(manager, {
kind: 'start_trial',
subscriptionId: subscription.id,
idempotencyKey: command.idempotencyKey,
});
});
// This operation is retried safely with the same idempotency key.
await this.trialProvisioner.submitToProvider(operation);
return this.subscriptions.getRequired(operation.subscriptionId);
}
The provider adapter should return or later normalize enough information to establish:
- its stable subscription identifier;
- its authoritative trial start and end times;
- the provider’s view that a trial actually exists;
- any provider-specific action required to collect payment details.
Only then should your domain service write:
subscription.status = SubscriptionStatus.TRIALING;
subscription.trialStartedAt = confirmedTrial.startAt;
subscription.trialEndsAt = confirmedTrial.endAt;
If the provider call times out, leave the operation recoverable rather than creating a second provider subscription. The operation’s saved idempotency key is the bridge between the local intent and a retry.
Expire trials by time, but enforce the deadline at access time
A scheduled worker should move expired trials out of trialing. Its job is operationally important because it produces an audit record, starts any necessary collection workflow, and updates the subscription summary shown to the customer.
Its query is conceptually:
SELECT id
FROM subscriptions
WHERE status = 'trialing'
AND trial_ends_at <= now()
ORDER BY trial_ends_at
FOR UPDATE SKIP LOCKED;
For each locked subscription:
- recheck that it is still
trialing; - change it to
payment_pending; - append a transition audit record with reason
trial_expired; - commit;
- then perform any provider action that is not already automatic.
However, scheduled work is not a security boundary. A worker can run late, fail, or be paused during a deployment. The access decision itself must check the timestamps:
canUseSubscription(subscription: SubscriptionEntity, now: Date): boolean {
if (
subscription.status === SubscriptionStatus.TRIALING &&
subscription.trialStartedAt &&
subscription.trialEndsAt
) {
return now >= subscription.trialStartedAt &&
now < subscription.trialEndsAt;
}
return subscription.status === SubscriptionStatus.ACTIVE &&
subscription.paidAccessConfirmedAt !== null;
}
For now, this is a deliberately narrow access rule. In the entitlement lesson, you will combine it with plan features, paid-period boundaries, configurable grace periods, and machine-readable denial reasons. The crucial behavior already exists: a stale trialing row cannot grant access after its end timestamp.
Use PostgreSQL time or a consistently injected application clock for comparisons. Avoid browser time, and avoid accepting a timestamp sent by the frontend as the authoritative current time.
Convert to paid only from verified billing evidence
At the trial boundary, the provider may create an invoice, charge a saved method, request customer authentication, pause collection, or wait for manual payment. Those outcomes are not interchangeable.
Your normalized provider event for a successful conversion should carry evidence such as:
export interface SettledFirstPaidPeriod {
providerName: string;
providerSubscriptionId: string;
providerInvoiceId: string;
providerSubscriptionStatus: 'active';
invoiceStatus: 'paid';
periodStart: Date;
periodEnd: Date;
occurredAt: Date;
}
The exact source differs by adapter, but acceptance in the domain layer should be strict:
private isConfirmedTrialConversion(
subscription: SubscriptionEntity,
event: SettledFirstPaidPeriod,
): boolean {
return (
subscription.providerSubscriptionId === event.providerSubscriptionId &&
event.providerSubscriptionStatus === 'active' &&
event.invoiceStatus === 'paid' &&
subscription.trialEndsAt !== null &&
event.periodStart >= subscription.trialEndsAt
);
}
The periodStart comparison protects against treating a zero-cost trial record or an unrelated invoice as the first paid period. A provider adapter may need to express this more directly, for example with an isFirstPostTrialPeriod field derived from its authoritative subscription snapshot.
When a verified event arrives, process the conversion in one database transaction:
async applySettledFirstPaidPeriod(event: SettledFirstPaidPeriod) {
await this.dataSource.transaction(async (manager) => {
const subscription = await this.subscriptions.lockByProviderReference(
manager,
event.providerName,
event.providerSubscriptionId,
);
if (!this.isConfirmedTrialConversion(subscription, event)) {
throw new InvalidProviderTransitionError('Unconfirmed trial conversion');
}
const alreadyRecorded = await this.billingPeriods.existsByProviderInvoice(
manager,
event.providerName,
event.providerInvoiceId,
);
if (alreadyRecorded) {
return;
}
await this.billingPeriods.recordSettled(manager, {
subscriptionId: subscription.id,
providerName: event.providerName,
providerInvoiceId: event.providerInvoiceId,
startsAt: event.periodStart,
endsAt: event.periodEnd,
});
subscription.status = SubscriptionStatus.ACTIVE;
subscription.paidAccessConfirmedAt = event.occurredAt;
await manager.save(subscription);
await this.transitions.append(manager, {
subscriptionId: subscription.id,
fromStatus: SubscriptionStatus.PAYMENT_PENDING,
toStatus: SubscriptionStatus.ACTIVE,
reason: 'first_paid_period_settled',
providerReference: event.providerInvoiceId,
});
});
}
In a complete implementation, permit TRIALING as a valid prior state too, because settlement can arrive before the expiry worker processes the subscription. The row lock and unique provider invoice reference make the result safe even when the expiry job and a provider event are processed at nearly the same time.
Stripe’s testing guide illustrates the sequence you should simulate: a trial exists, a reminder can be sent before it ends, then a paid invoice and subscription state arrive at conversion.
Use this as a testing model for lifecycle timing and event-driven provisioning. The specific event names belong to Stripe; the lesson is to normalize their meaning behind your provider adapter rather than expose them throughout your application.
Read “Test subscription trial periods.” Begin at the trial creation result, then follow the remaining dated steps through the first renewal. Next, in “Test subscription webhook notifications,” inspect the event table rows for customer.subscription.trial_will_end, invoice.paid, invoice.payment_action_required, and invoice.payment_failed. Read from invoice finalization through authentication required. Notice that an invoice becoming ready, and an invoice being paid, are different lifecycle facts.
A trial-ending reminder is only a notification. It must not alter entitlement state. A payment-authentication-required event is also not a conversion. Keep the subscription in payment_pending and direct the customer to complete the provider flow.
Test the negative paths, not only the happy path
A trial flow often passes manual testing because the happy path is simple. The important tests establish that invalid evidence cannot grant paid access.
Use a controllable clock in your domain tests, and use provider test clocks or your fake adapter for integration tests. Cover at least this matrix:
| Scenario | Expected local state | Access result |
|---|---|---|
| Provider confirms a newly created trial | trialing | Allowed before the deadline |
| Trial deadline passes, no payment method | payment_pending or paused | Denied |
| Payment method is attached during trial | trialing | Trial access only |
| Checkout return succeeds but payment is not confirmed | payment_pending | Denied |
| First invoice requires authentication | payment_pending | Denied |
| First invoice payment fails | payment_pending | Denied |
| Matching first paid invoice and active provider snapshot | active | Allowed |
| Duplicate settled-invoice delivery | active, one billing period | Allowed, no duplicate transition |
| Expiry worker runs late | stale trialing row possible | Denied after trialEndsAt |
| Payment event and expiry worker race | one valid final transition | Paid access only if settlement is verified |
The later webhook module will formalize raw-body verification, inbox persistence, replay handling, event ordering, and retries. For this lesson, ensure the domain method is already conservative: it accepts only normalized, provider-linked settlement evidence and remains safe if that evidence is delivered more than once.
Key takeaways
A sound trial implementation rests on four decisions:
- Model trial access and paid access as different entitlement bases.
- Store provider-confirmed trial dates, and enforce the trial deadline during access evaluation rather than trusting a scheduler alone.
- Treat adding a payment method, initiating a checkout, and creating an invoice as intermediate states—not as proof of payment.
- Transition to paid access only when a provider-linked first paid billing period is confirmed as settled, recording that evidence transactionally.
Next, you will implement subscription changes: using provider quotes for immediate upgrades and seat increases, scheduling downgrades and seat decreases for a future billing period, and ensuring a requested seat quantity never drops below assigned seats.
Can't find a good explanation? Sign up and we'll make it for you
Sign up