App Configuration
Edit config/app.ts to customize your app branding and settings:
Application settings are configured in config/app.ts. This includes branding (name, logo, colors), the business model (B2C or B2B), feature flags, legal page links, and social media URLs.
Pricing Configuration
All payment products are defined in config/pricing.ts, not in the database. This file is the single source of truth for subscriptions, credit packs, and licenses. Each product supports multi-currency pricing with environment-specific Stripe Price IDs.
Supported Currencies
Currencies are configured in config/pricing.ts. CURRENCY_CODES is the canonical tuple, and localeCurrencyMap decides which currency is selected for each locale:
// config/pricing.ts
export const CURRENCY_CODES = ['EUR', 'USD', 'GBP', 'CAD', 'CHF'] as const
localeCurrencyMap: {
'fr-FR': 'EUR',
'fr-CH': 'CHF',
'en-US': 'USD',
'en-CA': 'CAD',
'en-GB': 'GBP',
},
// Currency and SUPPORTED_CURRENCIES derive from CURRENCY_CODES.Plans are defined in code, not in the database. The subscriptions.plan_id column stores a reference to the plan ID in the config. All text uses translation keys (nameKey, descriptionKey) instead of hardcoded strings.
Stripe Price IDs
This is the most important Stripe step after creating products in the Stripe Dashboard. The app does not look up products by name, and pricing products are not stored in the database. It sends Stripe the exact price_... value resolved from config/pricing.ts.
Stripe gives you both prod_... product IDs and price_... price IDs. Paste only price_... values into stripePriceIds. Test-mode and live-mode prices are separate, so every paid item normally needs one dev ID and one prod ID per currency.
All Stripe Price IDs are organized at the top of config/pricing.ts with separate dev/prod values per currency:
// config/pricing.ts - Stripe Price IDs
const stripePriceIds = {
// Subscription plans: plan -> interval -> currency -> env
pro: {
monthly: {
EUR: { dev: 'price_xxx', prod: 'price_yyy' },
USD: { dev: 'price_xxx', prod: 'price_yyy' },
},
yearly: {
EUR: { dev: 'price_xxx', prod: 'price_yyy' },
},
},
// Credit packs: pack -> currency -> env
'pack-2000': {
EUR: { dev: 'price_xxx', prod: 'price_yyy' },
USD: { dev: 'price_xxx', prod: 'price_yyy' },
},
// Licenses: product -> currency -> env
'pro-lifetime': {
EUR: { dev: 'price_xxx', prod: 'price_yyy' },
USD: { dev: 'price_xxx', prod: 'price_yyy' },
},
}| What you create in Stripe | Where to paste the price_... ID |
Stripe price mode |
|---|---|---|
| Pro monthly subscription in EUR | stripePriceIds.pro.monthly.EUR.dev or .prod |
Recurring, monthly |
| Pro yearly subscription in USD | stripePriceIds.pro.yearly.USD.dev or .prod |
Recurring, yearly |
| Business monthly/yearly subscription | stripePriceIds.business.monthly|yearly.CURRENCY.dev|prod |
Recurring |
| Credit pack | stripePriceIds['pack-2000'].EUR.dev, etc. |
One-time |
| Lifetime or yearly license | stripePriceIds['pro-lifetime'].EUR.dev, stripePriceIds['business-yearly'].USD.prod, etc. |
One-time |
getPriceId() chooses dev unless NEXT_PUBLIC_INSTANCE_MODE=production. Local development should use Stripe test-mode keys and dev price IDs. Production should use live keys, live webhook secrets, and prod price IDs.
Subscription Plans
Recurring subscription plans are defined in the plans array. Each plan has monthly and yearly prices per currency, included credits, feature flags, and limits:
// config/pricing.ts - plans array
plans: [
{
id: 'free', // Stored in subscriptions.plan_id
nameKey: 'pricing.plans.free.name', // Translation key
descriptionKey: 'pricing.plans.free.description',
prices: {
monthly: {
EUR: { amount: 0, stripePriceId: '...' }, // No Stripe ID = free plan
USD: { amount: 0, stripePriceId: '...' },
},
},
featureKeys: [
{ nameKey: 'pricing.features.aiChat', included: true },
{ nameKey: 'pricing.features.apiAccess', included: false },
],
credits: { included: 100 }, // Monthly credit allocation
limits: { projects: 1, teamMembers: 1 },
ctaKey: 'pricing.cta.startFree',
ctaAction: 'checkout', // 'checkout' | 'contact'
},
{
id: 'pro',
nameKey: 'pricing.plans.pro.name',
badgeKey: 'pricing.badge.popular', // Optional badge
highlighted: true, // Highlighted card
prices: {
monthly: {
EUR: { amount: 29, stripePriceId: 'price_xxx' },
USD: { amount: 32, stripePriceId: 'price_xxx' },
},
yearly: {
EUR: { amount: 290, discount: 17, stripePriceId: 'price_xxx' },
},
},
credits: {
included: 2500,
additional: { // Optional: buy extra credits
amount: 1000,
pricesByCurrency: { EUR: 10, USD: 11 },
},
},
limits: { projects: 10, teamMembers: 5 },
ctaKey: 'pricing.cta.startTrial',
ctaAction: 'checkout',
trialDays: DEFAULT_TRIAL_PERIOD_DAYS, // Optional: free trial in days
},
]The pricing display is config-driven. The pricing table derives included plan credits from credits.included, license credits from credits.oneTime, project limits from limits.projects, trial length from trialDays, and trial credits from the top-level defaultTrialCredits. Do not duplicate numeric values such as "1M credits" or "10 projects" in translation files when the value already exists in config/pricing.ts.
Plans without a stripePriceId are treated as free plans. They bypass Stripe checkout and are created directly in the database via /api/billing/subscribe-free.
Free Trial
Subscription plans can offer a free trial by setting trialDays on the plan config. When set (and the plan is purchased via subscription checkout), Stripe applies trial_period_days so the customer is not charged until the trial ends. Stripe fires customer.subscription.trial_will_end 3 days before expiry, which is handled by applyTrialWillEnd() in core/billing/mutations.ts (queues a localized email via the email queue).
The trial length is driven by the DEFAULT_TRIAL_PERIOD_DAYS environment variable, read once at server startup in config/pricing.ts. Per anti-pattern B1, never read process.env.DEFAULT_TRIAL_PERIOD_DAYS outside this file — import the resolved plan.trialDays instead.
DEFAULT_TRIAL_PERIOD_DAYS value | Effective trialDays | Behavior |
|---|---|---|
| unset / blank | 7 | Default — 7-day trial |
0 | 0 | Trials disabled (checkout short-circuits) |
14, 30, etc. | parsed integer | Custom trial length |
> 730 | 730 | Clamped to Stripe's max trial_period_days |
| negative / non-numeric | 7 | Falls back to default |
The dashboard renders a TrialBanner (components/billing/trial-banner.tsx) when subscriptions.status === 'trialing', reading trial_end from the Stripe-mirrored row to show "Trial ends in N days" with a localized end date. The pricing card surfaces pricing.trial.badge next to plans that opt in and pricing.trial.creditsBadge when DEFAULT_TRIAL_CREDITS grants trial credits.
Both sidebars (components/private/sidebar.tsx + components/org/sidebar.tsx) also surface a compact trial period badge inside the credits widget (i18n keys sidebar.trial.badge + sidebar.trial.endsOn). The badge stays visible in B2B mode on /private-dashboard even when the End-Trial CTA is hidden (the CTA lives only on /org-dashboard in B2B). Data piggybacks on the existing access check — AccessCheckResult.details.trialEnd is added to the batched checkFirstAccountWithAccess SELECT so the badge costs zero extra round-trips. Defensive check: trialEnd must be a future timestamp; stale post-conversion values silently hide the badge.
Trial credits
To let trialing users actually use the app, set DEFAULT_TRIAL_CREDITS to a positive integer. The webhook then grants this amount on customer.subscription.created when status='trialing', capped at the plan's full credits.included (never overgrants). When the trial converts and the first invoice.paid fires, the webhook tops up the difference: add_credits(plan.credits.included - trialAmount). Net effect: the user converges to the full monthly allotment, never exceeds it. Subsequent monthly invoices grant the full amount as normal.
Idempotency: a distinct sentinel payments row (stripe_session_id = sub_trial_<subscription_id>) gates the trial grant, separate from the sub_init_ sentinel used for non-trial signup. The top-up branch in applyInvoicePaid() detects "first invoice after trial" by counting paid invoice payments rows for that subscription — if the count is ≤ 1, we're at the conversion point and subtract the trial amount from the refill. The manual endTrialEarly() path uses the same plan-minus-trial-credit calculation and writes an end_trial_<subscription_id> sentinel so the later Stripe webhook cannot double-grant.
The subscriptions table mirrors trial_end from Stripe on every customer.subscription.* webhook (see applySubscriptionChange()). This lets the dashboard banner render without round-tripping to the Stripe API. A partial index subscriptions_trial_end_idx covers the trialing-status hot path.
End Trial Early
Trialing users can end the trial immediately and unlock the full plan credits without waiting for trial_end. The dashboard banner (TrialBanner) and the credits widget in the sidebar both surface an "End trial" CTA that opens a confirmation dialog and posts to POST /api/billing/end-trial (CSRF + rate-limited via apiSecurity.authenticated(), body Zod-validated as { accountId: uuid }).
The route delegates to endTrialEarly() in core/billing/mutations.ts. The mutation runs in two steps:
- Stripe API —
stripe.subscriptions.update(subId, { trial_end: 'now' })ends the trial and lets Stripe generate the conversion invoice. Do NOT passproration_behavior: 'none': that suppresses the invoice entirely, soinvoice.paidnever fires and credits never arrive until the next billing cycle. The original implementation had this bug; it's now fenced off with an in-line comment. - Synchronous credit grant — the mutation immediately writes a sentinel
paymentsrow withstripe_session_id = end_trial_${stripeSubId}and calls theadd_creditsRPC forcreditsIncluded - trialAmountAlreadyGranted. The customer sees credits the moment the confirmation dialog closes.
To prevent double-grant when the webhook eventually arrives, applyInvoicePaid() looks up the same end_trial_ sentinel; when it exists AND priorRefillCount ≤ 1 (meaning this is the first invoice on the sub), the handler skips the refill RPC. Subsequent monthly invoices ignore the sentinel and refill normally.
Why the sync grant matters: dev environments without stripe listen never receive the webhook, customers without a payment method on file never trigger invoice.paid (Stripe creates the invoice but can't pay it), and even when everything works the multi-second webhook delay creates a confusing UX gap. The sync grant + sentinel idempotency pattern eliminates all three failure modes while keeping the math correct on retries.
Typed result codes: NO_SUBSCRIPTION (404), NO_TRIAL (409), STRIPE_ERROR (502 — usually a missing payment method). Credit-grant failures after the sentinel is written log to error_logs with event end_trial_add_credits_failed for admin recovery.
| Visibility gate | Where | Rule |
|---|---|---|
| Status | Banner + sidebar | Only when subscription.status === 'trialing' |
| Role | Banner + sidebar | Personal account: implicit owner. Workspace: hasRole(['owner','admin']) in B2C/hybrid, ['owner'] in B2B |
| Mode | /private-dashboard only | In B2B mode the button is hidden — billing actions route exclusively through /org-dashboard |
Purchase Confirmation Emails
Three customer-facing transactional emails are sent automatically on successful payment, directly through the active provider (whichever EMAIL_PROVIDER is set to: brevo, mailjet, or noop) rather than via the queue — customers expect inbox arrival to be immediate:
| Notification kind | Triggered by | Webhook handler |
|---|---|---|
subscription_started | customer.subscription.created (status active or trialing, first time only) | applySubscriptionChange |
credit_pack_purchased | checkout.session.completed with type='credit_pack' after credits granted | applyCheckoutCompleted |
license_purchased | checkout.session.completed with type='license' after license created | applyLicensePurchase |
All three call sendBillingNotificationNow(accountId, kind, payload) from lib/email/billing-notifications.ts. The helper:
- Resolves the account owner's email via a two-step lookup:
accounts.owner_user_id→profiles.email. Don't use Supabase's embeddedprofiles:owner_user_id(...)select —accounts.owner_user_idhas its FK pointed atauth.users(id), notprofiles(id), so PostgREST can't auto-resolve the relationship. - Renders the email via
buildBillingNotificationEmail()— a single shared HTML shell with kind-specific copy (subject, title, intro, body) plus an optional fact list (Plan / Product / Credits / Amount / Expires). All dynamic strings are HTML-escaped. - Calls
getEmailProvider().sendEmail()synchronously. - Falls back to
queueEmailon provider failure so a transient outage still recovers via theprocess-pending-emailscron retry job.
The 7 non-purchase notification kinds (payment_failed, trial_will_end, dispute_created, payment_action_required, async_payment_succeeded, async_payment_failed, checkout_expired) keep using the queued path via queueBillingNotification — they're not time-sensitive from the customer's POV, and queueing keeps the webhook handler fast.
Idempotency: all three triggers sit AFTER a UNIQUE-constraint-gated write earlier in the same handler (payments.stripe_session_id for credit packs and licenses; the isNewSubscription guard for subscriptions). A Stripe webhook retry returns at the gate before the email call is reached, so no double-send.
Translation keys live under email.billingNotif.* in i18n/messages/{fr-FR,fr-CH,en-US,en-CA}.json — the same canonical i18n catalogues used everywhere else, kept in lockstep by the parity test in __tests__/i18n/key-parity.test.ts. Billing notification subjects also come from these locale catalogues through getNotificationSubject(kind, locale); webhook and job handlers must pass a locale from the payload, owner profile, or configured default locale instead of hardcoding language inside the handler.
Other handler-sent emails follow the same rule: license-expiration warnings use email.licenseExpiration.*, organization deletion notices use email.orgDeleted.*, and interpolation goes through formatEmailTranslation() so copy stays aligned across all configured locales.
Pending-emails worker
The process-pending-emails cron handler (lib/jobs/handlers.ts) processes the queue. It claims rows through the service-role-only claim_pending_emails(p_limit) RPC, which uses FOR UPDATE SKIP LOCKED, moves rows to processing, increments attempts, and makes rows stuck in processing for 15+ minutes reclaimable. This prevents overlapping cron runs from sending the same queued email twice.
The worker supports three email_type branches: 'contact', 'newsletter', and 'notification' (the third was missing for a long time, which silently dropped every billing notification — a recently-fixed regression). The pending_emails.email_type CHECK constraint is scoped to exactly these three values, so an unsupported type fails loudly at insert time rather than being queued and silently permanent-failed; the worker still rejects any unrecognized type as defense-in-depth so it never loops.
Send failures (per attempt + at exhaustion) write to error_logs with events pending_email_send_failed and pending_email_send_threw. If the worker cannot persist a sent/retry/failed status back to pending_emails, it logs pending_email_status_update_failed and keeps the job result visibly failed. The worker promotes send failures to level: 'critical' once retries are exhausted — failures that warrant attention bubble up in the admin dashboard's severity filters.
Provider-level send failures ALSO write to error_logs from inside the provider modules (lib/email/providers/{brevo,mailjet}.ts): events like mailjet:send_failed, mailjet:send_exception, brevo:sendEmail:failed, brevo:sendEmail:threw. So a single missing email leaves a trail at two levels — provider HTTP response and worker outcome — useful for cross-checking when delivery silently fails.
Credit Packs (One-Time Purchase)
Credit packs are one-time purchases defined in the creditPacks array. Users buy extra credits on top of their subscription allocation:
// config/pricing.ts - creditPacks array
creditPacks: [
{
id: 'pack-500',
nameKey: 'pricing.creditPacks.starter', // Translation key
credits: 500, // Credits granted on purchase
pricesByCurrency: {
EUR: { price: 5, stripePriceId: 'price_xxx' },
USD: { price: 6, stripePriceId: 'price_xxx' },
GBP: { price: 4, stripePriceId: 'price_xxx' },
},
},
{
id: 'pack-2000',
nameKey: 'pricing.creditPacks.popular',
credits: 2000,
popular: true, // Highlight as popular
discount: 10, // Show discount badge (%)
pricesByCurrency: {
EUR: { price: 18, stripePriceId: 'price_xxx' },
USD: { price: 20, stripePriceId: 'price_xxx' },
},
},
]Licenses (One-Shot Payment)
Licenses are one-time payments granting access for a period or lifetime. They are defined in the licenses array and used when billingModel is 'license' or 'hybrid' in config/app.ts:
// config/pricing.ts - licenses array
licenses: [
{
id: 'pro-lifetime',
nameKey: 'pricing.licenses.proLifetime.name',
descriptionKey: 'pricing.licenses.proLifetime.description',
badgeKey: 'pricing.badge.bestValue', // Optional badge
highlighted: true,
type: 'lifetime', // 'lifetime' | 'yearly' | 'monthly'
validityDays: null, // null = never expires
pricesByCurrency: {
EUR: { price: 299, stripePriceId: 'price_xxx' },
USD: { price: 329, stripePriceId: 'price_xxx' },
},
featureKeys: [
{ nameKey: 'pricing.features.lifetimeAccess', included: true },
{ nameKey: 'pricing.features.futureUpdates', included: true },
],
credits: { oneTime: 5000 }, // One-time credit grant
limits: { projects: 10, teamMembers: 5 },
ctaKey: 'pricing.cta.buyNow',
ctaAction: 'checkout',
},
{
id: 'pro-yearly',
nameKey: 'pricing.licenses.proYearly.name',
type: 'yearly',
validityDays: 365, // Expires after 365 days
pricesByCurrency: {
EUR: { price: 99, stripePriceId: 'price_xxx' },
},
credits: { oneTime: 2500 },
limits: { projects: 10, teamMembers: 3 },
ctaKey: 'pricing.cta.buyNow',
ctaAction: 'checkout',
},
]Currency & Locale Mapping
The config maps locales to currencies and sets display defaults:
// config/pricing.ts - top-level config
export const pricingConfig: MultiCurrencyPricingConfig = {
defaultInterval: 'monthly', // Default toggle position
showToggle: true, // Show monthly/yearly toggle
showComparison: true, // Show feature comparison table
defaultCurrency: 'EUR', // Set via npm run init (Step 2b)
defaultTrialCredits: DEFAULT_TRIAL_CREDITS,
allowPromotionCodes: true, // Show promo code field at Stripe checkout
localeCurrencyMap: { // Locale -> Currency mapping
'fr-FR': 'EUR',
'fr-CH': 'CHF',
'en-US': 'USD',
'en-CA': 'CAD',
'en-GB': 'GBP',
},
plans: [...],
creditPacks: [...],
licenses: [...],
}Resolving Pricing for Display
Use the resolvePricingConfig() function to get locale-specific pricing data. It resolves translation keys to actual text, selects the correct currency based on the user's locale, and derives numeric feature values from config: included monthly credits, one-time license credits, project limits, and trial credits. The resolved config powers the pricing table, checkout flow, and plan comparison components.