AlmostShipped

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.enabledallowTrialWithCreditsBehavior
false (default)(ignored)price.trialPeriodDays is honored. Pure subscription product, classic free trial.
truefalse (default)Trial silently disabled, one-time warn in logs. The badge ("N-day free trial") auto-hides.
truetrueTrial 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:

  1. Trial subscriber checks out with a valid card.
  2. customer.subscription.created fires with status trialing.
  3. 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.
  4. Trial ends, status flips to active, full credit grant fires.
  5. 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 trialGrant to 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:

PatternTypical products
Free tier + creditsClaude, ChatGPT, Lovable, Suno, Midjourney
Free trial, seat-basedLinear, 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.

  • @see AUDIT-TRIAL-CREDITS — the original audit identifying the missing trial → active grant transition handler.
  • @see AUDIT-TRIAL-ENFORCEMENT — maps the checkout path that this resolver feeds.
  • @see FIX-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.

On this page