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.
Operational settings and statistics
config/membership-role-reads.ts bounds Account membership role reads to five-second read-only Prisma transactions and two-KiB receipts. The reader verifies canonical identity and profile revocation, then returns only the exact actor/Account pair, role and role slug. Role slugs retain the existing 100 UTF-16-unit limit. Billing authority still comes from getBillingManagerRoles(): owner in B2B, owner or admin otherwise. A platform admin flag does not bypass Account membership.
config/payment-providers.ts defines the client-safe payment and billing provider catalogues and their derived types. Consumers use those tuples for validation instead of repeating literal lists. Credentials and operational settings remain server-only in config/payments.ts.
config/subscription-control-begin.ts bounds durable subscription command creation to five-second Prisma transactions and eight-KiB receipts. Canonical identity, profile and Account authority are locked. Billing roles remain configuration-owned and are checked before commit. A new administrative command and its derived audit commit together; replay does not duplicate either. Receipt rejection or audit failure rolls back the intent before provider I/O.
config/admin-subscription-reads.ts bounds single-subscription reads to a five-second read-only Prisma transaction and a 16-KiB result. The GET includes Account name and balance; command preparation reads only subscription fields. Both validate the canonical administrator before returning a row or absence, preserve current and historical records and retain timestamp microseconds. Field limits follow the subscription list. Read failures stop command preparation and are logged explicitly.
config/admin-subscriptions-list.ts owns the list API default of 100 rows and maximum of 200, a five-second read-only Prisma transaction and a two-MiB result ceiling. Only current subscriptions are listed. Rows, count and Account labels share one canonical administrator snapshot; labels are obtained after pagination. Name/email limits are 512/320 UTF-8 bytes, plan/provider status 256, billing hold reason 512 and external references 1,020. Invalid selected values fail explicitly, and an empty page retains the exact total.
config/admin-payments-list.ts bounds the administrative payment list to the existing 100-row page limit, a five-second read-only Prisma transaction and a 256-KiB result. Account names, owner emails and offer identifiers have 512/320/256-byte limits. Labels are read only for the selected page; the exact total remains available on an empty page. Rows, labels and count share one canonical administrator snapshot. Invalid selected fields or amounts fail explicitly.
config/admin-billing-stats.ts bounds subscription and one-time payment statistics to five-second read-only Prisma transactions and 16-KiB receipts. Each reader verifies the canonical administrator in its snapshot. Currency totals retain fractional annual-plan MRR and recognized net revenue after refunds. Month and year boundaries use the database timezone. Invalid or unsafe totals fail explicitly; subscription command persistence retains its separate implementation.
config/admin-analytics-summary.ts controls the Analytics summary: current/previous 30-day periods, a seven-day activity window, five-second read-only transaction and 16-KiB result ceiling. Historical counts and period aggregates share one canonical administrator snapshot. Today starts at server-local midnight; rolling windows use elapsed days. Only positive latencies contribute to the rounded average. Invalid totals fail explicitly. The SQL timeout is set before verification and respects stricter pool limits.
config/admin-analytics-credits.ts bounds the Analytics credit totals to a five-second read-only Prisma transaction, a four-KiB result and a window of 30 elapsed days (720 hours). Consumed credits include negative ledger entries; purchased credits retain all nonnegative sources, including grants. Both totals share one canonical administrator snapshot. Invalid or unsafe totals fail explicitly. Credit balance mutations retain their atomic RPCs.
config/admin-analytics-rankings.ts bounds the user and Account rankings to ten rows each, a 32-KiB result and a five-second read-only transaction. Both rankings share a 30-day rolling cutoff and canonical administrator snapshot. Account names and email labels are read for selected results only, with limits of 512 and 320 UTF-8 bytes respectively. Personal, workspace and guest Accounts remain included. Invalid selected labels or totals fail explicitly; other Analytics statistics retain their own reads.
config/admin-analytics-breakdown.ts controls the combined model and agent statistics: a 30-day rolling window, five-second read-only transaction, 200 groups per category, 256 UTF-8 bytes per name and a 256-KiB result ceiling. Both categories share one canonical administrator snapshot. Exceeding a limit fails the read instead of returning partial statistics. Costs retain 1/10,000-cent precision; exact aggregation remains subject to the database deadline on large datasets.
config/admin-analytics-daily.ts bounds the daily Analytics series to 30 selected dates, a 16-KiB receipt and a five-second read-only Prisma transaction. The rolling cutoff and displayed UTC day keys are preserved, including fractional costs at 1/10,000-cent precision. Only requested chart days are aggregated and returned. Canonical administrator verification precedes data access, and invalid totals fail instead of appearing as zero activity. Other Analytics aggregates retain their current implementation.
config/admin-overview-usage.ts owns the overview usage window (30 rolling days), five-second database deadline and four-KiB result ceiling. Users, workspaces and conversations are all-time counts; AI requests and tokens include rows at or after the window start. These five metrics share one read-only Prisma snapshot after canonical administrator verification. Revenue metrics retain their separate billing reads. The locale-only shared cache is removed; failures never become zero statistics. Exact aggregates may still scan many rows and can time out on large datasets.
config/jobs-admin-handler-delete.ts sets the five-second handler deletion deadline. Shared Prisma checks references, removes the handler and writes one audit atomically, with adminEscalation. A short SHARE lock on jobs serializes the check with in-flight writes; handlers in use return 400 and missing handlers remain successful. This briefly delays all job writes. It is not a foreign key: the existing job creation contract still accepts future arbitrary names, including code-only handlers. Direct authenticated handler deletion is closed.
config/jobs-admin-handlers.ts owns handler field limits, a maximum of 50 headers, one-MiB write inputs, two-MiB detail receipts and five-second transactions. Handler names are immutable because jobs reference them. The editor uses presence flags for credentials and headers: omission preserves the stored value, replacement is explicit, null clears a credential and an empty header object clears headers. Selecting no authentication clears its credential. Creation and updates require adminEscalation and commit with their audit; no direct table path can create, update or read handler credentials. The browser webhook test has been removed because it cannot use stored server-side secrets.
config/jobs-admin-handlers-list.ts bounds the handler catalogue to 200 matching database handlers, a two-MiB result and a five-second read-only transaction. The API, administrative list and job creation selector share the same Prisma path and canonical administrator checks. Enabled-only filtering precedes the limit; oversized results fail explicitly. The list keeps its configured webhook URL and method but excludes headers and authentication values. Built-in handlers remain in the code registry.
config/jobs-admin-delete.ts sets the five-second job deletion transaction deadline. Deletion locks run history before jobs, then rechecks and locks canonical administrator authority. At most two native targets are considered: the stored reference and exact canonical job name. Native removal, job/run deletion and audit commit together. The adminEscalation step-up policy applies; no direct authenticated table deletion exists. Large cascades or conflicting operator transactions can still time out and roll back; no automatic retry is performed.
config/jobs-admin-detail.ts sets 20 recent runs for the detail API, 50 for the page, a two-MiB UTF-8 response ceiling and a five-second transaction deadline. Job and run data share one read-only Prisma snapshot after canonical administrator checks. SQL checks selected payload sizes before aggregation and the complete serialized receipt before transfer. Oversized results fail without truncation; unrelated or older unselected run payloads do not affect the request. Configuration and output retain their stored JSON shape for the administrator detail view.
config/jobs-admin-mutations.ts bounds serialized creation/update inputs to one MiB and the shared Prisma transaction to five seconds. SQL independently applies the same ceiling to normalized JSONB, which can expand scientific numbers; exceeding either input bound returns 400 without effects. Updates apply only supplied fields, with no creation defaults. Scheduling changes preflight native capabilities before effects; metadata-only changes leave schedules untouched. Job changes and a minimal audit commit together after canonical authority checks. Lock waits and SQL work are bounded; failed or ambiguous writes are not retried automatically.
config/jobs-admin-list.ts bounds the administrative jobs list to 200 matching results and a five-second transaction. The API and page share a read-only Prisma snapshot per list, returning eight display fields without job configuration or native cron identifiers. Enabled/executor filters apply before the bound; oversized lists fail explicitly. Names use deterministic C ordering followed by the job identifier. An output limit does not bound all rows scanned for selective filters.
The cron administration page and API read one private snapshot of the native scheduler. config/cron-admin.ts bounds the complete list to 200 entries and the transaction to five seconds. An overflow or an ambiguous link between a native entry and application jobs fails explicitly. Missing cron tables or execution history returns an unsupported outcome. All SQL command text is masked; the page still shows names, schedules, status, linked jobs and aggregate statistics.
The 24-hour history filter does not guarantee a bounded scan: the native history table can lack a time index, and its extension owner can be unavailable to the installer. The database deadline still cancels slow reads. Validate history indexing, retention and realistic volumes with the native scheduler operator before claiming production performance.
Cron toggle, unschedule and orphan purge use the same Prisma runtime. Native changes and audit commit atomically; SQL rechecks canonical administrator authority after waiting for the jobs lock. Purge preflights at most 200 native entries and rolls back entirely on failure. Missing native capabilities and foreign-owned targets return unsupported before any changes. Unschedule and purge retain their recent-auth requirement.
Job statistics and paginated run history use read-only Prisma snapshots with canonical administrator checks. config/jobs-admin-observability.ts sets a five-second transaction deadline, page size up to 100 and maximum offset 10,000. SQL computes all 24-hour aggregates, including the rounded average duration, independently of the presentation page size. History includes only seven metadata fields; payloads, errors and the triggering user identity are excluded. Timestamps preserve microseconds and pages have a stable order with an exact total from the same snapshot.
The platform Settings page reads stored operational parameters and ten system/log counters through shared Prisma. Both queries use one private read-only snapshot, so concurrent changes cannot mix the settings and statistics from different database states. The API and page verify the canonical platform administrator; no private settings or counts enter a shared cache.
The key/value list is ordered and limited to 200 entries by config/admin-settings.ts. Exceeding that limit fails explicitly rather than hiding entries. Missing keys stay absent, and database failures or invalid count values never become empty settings or zero statistics.
Changes to admin_email and jobs_api_url commit together with an audit identifying the setting and canonical administrator, without copying the email or URL value. Values remain limited to 500 characters; URL validation finishes before the database transaction. No direct authenticated table write can bypass these commands. Maintenance changes retain their separate atomic writer and existing cache.
Log purges also commit with their audit. Purging chat removes both messages and sessions and reports both counts; purging admin logs leaves the audit of that purge. A database timeout, failed audit or invalid result rolls back the entire operation. These synchronous commands use a bounded transaction; large or contended purges can fail without committing partial deletion.
With supabase_pg_cron, shared Prisma reconciles jobs_api_url with NEXT_PUBLIC_APP_URL on server startup and reprograms eligible HTTP cron tasks atomically. Localhost is skipped. The limits in config/jobs-cron.ts allow up to 200 tasks and five seconds per transaction; overflow, unavailable native extensions or a scheduling failure rolls back the operation and logs a redacted error without blocking startup. Fix the reported configuration issue and restart to try again. Saving a URL from Settings does not itself resynchronize existing cron jobs. postgres_pg_cron is unaffected by URL drift because every native entry runs a fixed, secret-free SQL enqueue command; its TypeScript execution boundary is the supervised pnpm jobs:worker process.
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, licenses, currencies, and environment-specific Stripe, Lemon Squeezy, and Paddle catalogue bindings. PAYMENTS_PROVIDER selects one provider server-side for the installation; the browser never selects it.
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.
Provider catalogue IDs
The application never looks up a paid offer by a provider dashboard name. It resolves one internal offer, optional interval, currency, environment, and the server-selected provider to an exact external catalogue ID in config/pricing.ts. Stripe and Paddle use Price IDs; Lemon Squeezy uses Variant IDs.
Stripe and Paddle Price IDs identify the sellable price; Lemon Squeezy checkout uses a numeric Variant ID. Do not paste a parent Product ID in their place. Test/sandbox and production objects are separate, so every sold combination needs the correct dev or prod binding for the selected provider.
All external IDs are organized at the top of config/pricing.ts with separate provider and dev/prod values:
// config/pricing.ts - provider catalogue IDs
const providerCatalogIds = {
'pro:monthly:EUR': {
stripe: { dev: 'price_test_xxx', prod: 'price_live_xxx' },
lemonSqueezy: { dev: 'numeric_variant_id', prod: '' },
paddle: { dev: 'pri_sandbox_xxx', prod: '' },
},
'pack-2000:EUR': {
stripe: { dev: 'price_test_xxx', prod: 'price_live_xxx' },
lemonSqueezy: { dev: 'numeric_variant_id', prod: '' },
paddle: { dev: 'pri_sandbox_xxx', prod: '' },
},
'pro-lifetime:EUR': {
stripe: { dev: 'price_test_xxx', prod: 'price_live_xxx' },
lemonSqueezy: { dev: 'numeric_variant_id', prod: '' },
paddle: { dev: 'pri_sandbox_xxx', prod: '' },
},
}| Commercial combination | Catalogue key | External object |
|---|---|---|
| Pro monthly subscription in EUR | providerCatalogIds['pro:monthly:EUR'] |
Recurring monthly Price/Variant |
| Pro yearly subscription in USD | providerCatalogIds['pro:yearly:USD'] |
Recurring yearly Price/Variant |
| Business monthly/yearly subscription | providerCatalogIds['business:monthly:CURRENCY'] / yearly |
Recurring Price/Variant |
| Credit pack | providerCatalogIds['pack-2000:EUR'] |
One-time Price/Variant |
| Lifetime or yearly license | providerCatalogIds['pro-lifetime:EUR'], etc. |
One-time Price/Variant |
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 }, // No provider binding = free plan
USD: { amount: 0 },
},
},
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, providers: getProviderCatalogBindings('pro', 'EUR', 'monthly') },
USD: { amount: 32, providers: getProviderCatalogBindings('pro', 'USD', 'monthly') },
},
yearly: {
EUR: { amount: 290, discount: 17, providers: getProviderCatalogBindings('pro', 'EUR', 'yearly') },
},
},
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 with amount 0 and no provider binding are free plans. They bypass external checkout and are created directly through /api/billing/subscribe-free.
Free Trial
Subscription plans can offer a free trial by setting trialDays on the plan config. The selected provider adapter translates that duration to its native checkout/subscription contract. Signed lifecycle events synchronize trial state; provider-specific trial-warning events may queue the localized reminder supported by that provider.
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: the trial grant uses a semantic ledger key for the provider subscription. Paid credits are granted only after a paid invoice or transaction is proven, using one key composed from provider, environment, subscription, catalogue item, and billing-period start. The signed paid webhook owns this financial operation; the first conversion top-up subtracts trial credits, and later deliveries or concurrent retries 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 may request an early conversion through POST /api/billing/end-trial. The strict Zod body contains only { accountId: uuid }; the route adds CSRF, strict rate limiting, billing step-up, and the shared billing-manager role gate. The CTA is rendered only when the persisted provider and environment match the installation and its end-trial implementation is ready. No provider identity crosses the client boundary.
The route delegates to requestEndTrialEarly() in core/billing/subscription-controls.ts. It commits an end_trial row in billing_subscription_control_commands before provider I/O, then performs at most one provider mutation with the journal-owned idempotency key. Stripe also copies that opaque command ID into subscription metadata so the immutable conversion invoice can prove which request caused it. A lost response becomes provider_unknown and is recovered only by provider reads.
The route waits for the bounded provider mutation. A provider acknowledgement returns 200 processing; only an ambiguous result that needs durable reconciliation returns 202 pending. Neither response claims that paid conversion has completed. Signed webhooks remain authoritative for local subscription state, normalized payment proof, and the period entitlement. Reconciliation completes the command only after the provider has left trial, the signed local subscription is active, and an immutable subscription_cycle invoice carries the exact command ID, remains strictly paid, covers exactly the signed subscription period, and starts no later than the recorded end of the one-shot provider mutation. Stripe is ready for this flow; Lemon Squeezy and Paddle remain fail-closed until their conversion payment routing and sandbox behavior are attested.
The UI deliberately accepts the webhook-delay window: it confirms that processing started, then refreshes from persisted state. Stable errors distinguish a missing subscription, an invalid or already-changing subscription, a forbidden actor, a provider capability that is unavailable, and an unexpected server failure without exposing provider details.
| 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
Customer-facing purchase emails are emitted only after the durable commercial effect they describe. Every member subscription, credit-pack, and licence confirmation is persisted in the pending_emails outbox before the billing event is acknowledged as complete.
| Notification kind | Triggered by | Delivery |
|---|---|---|
subscription_started (trial) | Signed lifecycle reaches trialing, after the trial credit ledger succeeds | Durable pending_emails outbox |
subscription_started (paid) | Initial invoice.paid or normalized provider payment, after payment proof and period ledger converge | Durable pending_emails outbox |
credit_pack_purchased | Paid checkout after the normalized payment and credit ledger converge | Durable pending_emails outbox |
license_purchased | Paid checkout after the normalized payment, licence, and included credit ledger converge | Durable pending_emails outbox |
customer.subscription.created only synchronizes an active paid subscription snapshot; it cannot send copy claiming payment or credits. Trial start is queued after its grant, while paid start is queued by the payment effect after provider proof and the period grant. Trial and paid phases have separate semantic idempotency keys over provider, environment and external subscription, so an unsent trial row cannot swallow the later paid confirmation and each phase remains replay-safe.
queueBillingNotificationOnce() persists member purchase confirmations behind pending_emails.idempotency_key. Subscription keys identify the provider, environment, external subscription, and commercial phase. Member one-time keys identify the provider, environment, normalized local payment, and purchase kind. If the outbox row cannot be proven, the billing event remains retryable. Guest checkout keeps its separate atomic claim-email outbox and does not enqueue a second member confirmation.
- Resolves the account owner's email through shared Prisma:
accounts.owner_user_id→private.app_users.idand its application profile. No provider-native identity relation or embedded provider query is involved. - 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. - The
process-pending-emailsworker claims the durable row and calls the activeEMAIL_PROVIDER. - A transient delivery failure remains in the retry path; webhook replay may prove the same outbox row but cannot enqueue a duplicate confirmation.
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: purchase confirmation keys are separate from normalized payment proof and credit or entitlement keys because each guards a different durable side effect. Webhook retries therefore converge without a second email for the same commercial phase or one-time purchase.
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 job handler (lib/jobs/handlers.ts) processes the queue through the shared worker runtime. Its bounded claim transaction uses FOR UPDATE SKIP LOCKED, moves rows to processing, increments attempts, and makes expired leases reclaimable. The dedicated worker credential cannot use administrator or maintenance capabilities, and overlapping runs cannot claim the same delivery.
The worker supports four email_type branches: 'contact', 'newsletter', 'notification' (billing emails carrying a kind discriminator that the worker renders from templates at send time), and 'admin' (admin alerts from sendAdminNotification(), whose HTML body is already rendered at queue time and forwarded as-is). The pending_emails.email_type CHECK constraint is scoped to exactly these four 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.
Each type maps to exactly one payload shape — do not queue a pre-rendered email as 'notification'. Admin alerts previously shared that type, so the worker rendered them through the billing template with no kind and delivered raw translation keys (billingNotif.undefined.subject) instead of the alert. The billing builder now throws on an unknown kind, so a mismatched payload fails loudly instead of mailing placeholder copy.
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, providers: getProviderCatalogBindings('pack-500', 'EUR') },
USD: { price: 6, providers: getProviderCatalogBindings('pack-500', 'USD') },
GBP: { price: 4, providers: getProviderCatalogBindings('pack-500', 'GBP') },
},
},
{
id: 'pack-2000',
nameKey: 'pricing.creditPacks.popular',
credits: 2000,
popular: true, // Highlight as popular
discount: 10, // Show discount badge (%)
pricesByCurrency: {
EUR: { price: 18, providers: getProviderCatalogBindings('pack-2000', 'EUR') },
USD: { price: 20, providers: getProviderCatalogBindings('pack-2000', 'USD') },
},
},
]Licenses (One-Shot Payment)
Licenses are one-time payments granting access for a period or lifetime. They are defined in the products array and used when billingModel is 'one_time' or 'hybrid' in config/app.ts:
// config/pricing.ts - licenses array
products: [
{
id: 'pro-lifetime',
nameKey: 'pricing.products.proLifetime.name',
descriptionKey: 'pricing.products.proLifetime.description',
badgeKey: 'pricing.badge.bestValue', // Optional badge
highlighted: true,
licenseType: 'lifetime', // 'lifetime' | 'yearly' | 'monthly'
validityDays: null, // null = never expires
pricesByCurrency: {
EUR: { price: 299, providers: getProviderCatalogBindings('pro-lifetime', 'EUR') },
USD: { price: 329, providers: getProviderCatalogBindings('pro-lifetime', 'USD') },
},
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.products.proYearly.name',
licenseType: 'yearly',
validityDays: 365, // Expires after 365 days
pricesByCurrency: {
EUR: { price: 99, providers: getProviderCatalogBindings('pro-yearly', 'EUR') },
},
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 pnpm run init (Step 2b)
defaultTrialCredits: DEFAULT_TRIAL_CREDITS,
allowPromotionCodes: true, // Stripe-only hosted-checkout capability
localeCurrencyMap: { // Locale -> Currency mapping
'fr-FR': 'EUR',
'fr-CH': 'CHF',
'en-US': 'USD',
'en-CA': 'CAD',
'en-GB': 'GBP',
},
plans: [...],
creditPacks: [...],
products: [...],
}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.