Trials & credits
Why turning on the credit system disables free trials by default, the fraud surface it closes, and how to opt back in when you accept the risk.
The credit system and free trials are both popular SaaS patterns, and both are first-class in this boilerplate. They don't, however, play well together by default — combining them creates a small but real fraud surface that we close with a single safe default. This page covers the tradeoff, the two product modes you can run, and the one config flag that switches between them.
TL;DR
credits.enabled | allowTrialWithCredits | Behavior |
|---|---|---|
false (default) | (ignored) | price.trialPeriodDays is honored. Pure subscription product, classic free trial. |
true | false (default) | Trial silently disabled, one-time warn in logs. The badge ("N-day free trial") auto-hides. |
true | true | Trial honored. Buyer has explicitly accepted the burn-and-cancel risk. |
The config change is one line:
// packages/payments/config.ts
credits: {
enabled: true,
allowTrialWithCredits: true, // opt-in
// ...
}The fraud surface
The credit system's webhook-grant.ts handler grants a full plan
allowance to a subscription as soon as it enters the active (or
trialing, when trialGrant.enabled === true) status. Stripe and Dodo
both create new subscriptions with status: "trialing" when the price
has a trial_period_days set — that's the entire mechanic of a free
trial.
So if a buyer turns on credits AND honors trials, the failure mode is:
- Trial subscriber checks out with a valid card.
customer.subscription.createdfires with statustrialing.- The default config grants 0 credits during trial (
trialGrant.enabled === false) but the trial UI still tells the user they have 7 days of free access. - Trial ends, status flips to
active, full credit grant fires. - Trial subscriber consumes the grant rapidly and cancels before the next billing cycle — they got the grant without paying.
Even with trialGrant.enabled === false (default), the experience is
confusing: the trial badge promises "7-day free trial" but credits don't
actually start until billing. Most buyers either want the trial with
credits (fraud surface) or no trial at all (credits start at signup).
The safe default
When credits.enabled === true and allowTrialWithCredits !== true,
the resolver returns undefined for any price's effective trial period.
JSON.stringify omits keys with undefined values, so the Stripe (and
Dodo) API call goes out with no subscription_data.trial_period_days
field at all — which Stripe interprets as "no trial". The subscription
becomes active immediately, the first webhook fires with active
status, the credit grant flows, and there's no trial window.
Returning literal 0 would break checkout: Stripe's API explicitly
rejects trial_period_days < 1 ("Has to be at least 1") with an
invalid_request_error. Omitting the field (via undefined) is the
only correct wire shape for "no trial".
The pricing badge on both the SaaS pricing table and the marketing
landing page reads from the same resolver, so the "N-day free trial"
badge disappears automatically when the resolver returns 0. The
marketing promise stays in lockstep with the checkout behavior.
A one-time logger.warn fires per planId on the first checkout that
would have honored a trial. This is intentional: it makes the conflict
visible to the operator without flooding logs.
When to opt in
Set credits.allowTrialWithCredits: true only if at least one of these
applies to your product:
- You've configured
trialGrantto cap trial credits at a small fixed amount (e.g.{ enabled: true, amount: 100 }) so trial users can't exhaust the full plan allowance. - Your credit costs are tiny relative to the plan allowance (e.g.
aiStream: 0.01) so even an aggressive trial burn doesn't dent margin meaningfully. - You require credit-card validation for trial signup AND you're comfortable with the chargeback rate that follows.
If none of these apply, leave the flag off and pair credits with a
proper free tier (paymentsConfig.plans.free + welcomeGrant) instead
of a paid-trial flow.
Industry context
The two design patterns roughly split along product axes:
| Pattern | Typical products |
|---|---|
| Free tier + credits | Claude, ChatGPT, Lovable, Suno, Midjourney |
| Free trial, seat-based | Linear, Notion, Slack, Figma, Cursor |
AI / metered products almost universally use a free tier with a small welcome grant because credit allowances are the fraud surface. Pure seat-based collaboration tools default to time-bounded trials because seats can be revoked instantly at trial end.
The boilerplate ships with credits.enabled: true and
welcomeGrant: { credits: 500 } to match the first pattern, since AI
SaaS is the typical buyer profile. Buyers building a Notion-style
collaboration tool should flip credits.enabled to false and trials
work normally — no other change required.
Related
@seeAUDIT-TRIAL-CREDITS — the original audit identifying the missing trial → active grant transition handler.@seeAUDIT-TRIAL-ENFORCEMENT — maps the checkout path that this resolver feeds.@seeFIX-TRIAL-DEFAULTS-V2 — the implementation section in the source.
The hardcoded "7-day free trial" strings in
packages/i18n/translations/*/shared.json (under
pricing.products.{essential,pro,enterprise}.features.trial) are
separate from the dynamic badge and will not auto-hide when the
resolver disables trials. Drop the keys or convert them to ICU plurals
if you want them to track the config.