@fonderie/billing 5.3.1 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/middlewares/require-plan.ts","../../src/services/subscriptions.ts","../../src/utils.ts","../../src/middlewares/billing.ts","../../src/config.ts","../../src/services/policy.ts"],"sourcesContent":["import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { getSubscription } from '../services/subscriptions';\nimport { resolveSubscriber } from '../utils';\n\n// Gates a route behind a minimum plan.\n// Works for both user-level and workspace-level subscriptions.\n// Usage: requirePlan(['pro', 'enterprise'], store)\n\nfunction makeHandler(plans: string | string[], store: IStoreAdapter): Middleware {\n\tconst allowed = Array.isArray(plans) ? plans : [plans];\n\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst subscriber = resolveSubscriber(ctx);\n\t\tif (!subscriber) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'SUBSCRIBER_REQUIRED', 'Subscriber context required');\n\t\t}\n\n\t\tconst subscription = await getSubscription(subscriber.type, subscriber.id, store);\n\n\t\tif (!subscription || !allowed.includes(subscription.plan)) {\n\t\t\treturn setApiResponse(\n\t\t\t\tHTTP.PAYMENT_REQUIRED,\n\t\t\t\t'PLAN_UPGRADE_REQUIRED',\n\t\t\t\t'Plan upgrade required',\n\t\t\t\t{ required: allowed, current: subscription?.plan ?? 'none' },\n\t\t\t);\n\t\t}\n\n\t\tif (subscription.status !== 'active' && subscription.status !== 'trialing') {\n\t\t\treturn setApiResponse(\n\t\t\t\tHTTP.PAYMENT_REQUIRED,\n\t\t\t\t'SUBSCRIPTION_INACTIVE',\n\t\t\t\t'Subscription is not active',\n\t\t\t\t{ status: subscription.status },\n\t\t\t);\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requirePlan(plans: string | string[], store: IStoreAdapter): Middleware;\nexport function requirePlan(\n\tplans: string | string[],\n\tstore: IStoreAdapter,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requirePlan(\n\tplans: string | string[],\n\tstore: IStoreAdapter,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(plans, store);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { ISubscription, SubscriberType } from '../types';\n\nconst SELECT_SUBSCRIPTION = `\n\tSELECT\n\t\tid,\n\t\tsubscriber_type AS \"subscriberType\",\n\t\tsubscriber_id AS \"subscriberId\",\n\t\tplan,\n\t\tinterval,\n\t\tstatus,\n\t\tprovider_customer_id AS \"providerCustomerId\",\n\t\tprovider_subscription_id AS \"providerSubscriptionId\",\n\t\tcurrent_period_start AS \"currentPeriodStart\",\n\t\tcurrent_period_end AS \"currentPeriodEnd\",\n\t\tcancel_at_period_end AS \"cancelAtPeriodEnd\",\n\t\ttrial_ends_at AS \"trialEndsAt\",\n\t\tcreated_at AS \"createdAt\"\n\tFROM fonderie_subscriptions`;\n\nexport async function getSubscription(\n\tsubscriberType: SubscriberType,\n\tsubscriberId: string,\n\tstore: IStoreAdapter,\n): Promise<ISubscription | null> {\n\tconst [row] = await store.query<ISubscription>(\n\t\t`${SELECT_SUBSCRIPTION} WHERE subscriber_type = $1 AND subscriber_id = $2`,\n\t\t[subscriberType, subscriberId],\n\t);\n\treturn row ?? null;\n}\n\nexport async function upsertSubscription(\n\tdata: {\n\t\tsubscriberType: SubscriberType;\n\t\tsubscriberId: string;\n\t\tplan: string;\n\t\tinterval?: 'month' | 'year';\n\t\tstatus: string;\n\t\tproviderCustomerId?: string;\n\t\tproviderSubscriptionId?: string;\n\t\tcurrentPeriodStart?: Date;\n\t\tcurrentPeriodEnd?: Date;\n\t\tcancelAtPeriodEnd?: boolean;\n\t\ttrialEndsAt?: Date | null;\n\t},\n\tstore: IStoreAdapter,\n): Promise<void> {\n\tawait store.query(\n\t\t`INSERT INTO fonderie_subscriptions\n\t\t\t(subscriber_type, subscriber_id, plan, interval, status,\n\t\t\t provider_customer_id, provider_subscription_id,\n\t\t\t current_period_start, current_period_end,\n\t\t\t cancel_at_period_end, trial_ends_at)\n\t\t VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)\n\t\t ON CONFLICT (subscriber_type, subscriber_id) DO UPDATE SET\n\t\t\t plan = $3,\n\t\t\t interval = $4,\n\t\t\t status = $5,\n\t\t\t provider_customer_id = COALESCE($6, fonderie_subscriptions.provider_customer_id),\n\t\t\t provider_subscription_id = COALESCE($7, fonderie_subscriptions.provider_subscription_id),\n\t\t\t current_period_start = $8,\n\t\t\t current_period_end = $9,\n\t\t\t cancel_at_period_end = $10,\n\t\t\t trial_ends_at = $11`,\n\t\t[\n\t\t\tdata.subscriberType,\n\t\t\tdata.subscriberId,\n\t\t\tdata.plan,\n\t\t\tdata.interval ?? 'month',\n\t\t\tdata.status,\n\t\t\tdata.providerCustomerId ?? null,\n\t\t\tdata.providerSubscriptionId ?? null,\n\t\t\tdata.currentPeriodStart ?? null,\n\t\t\tdata.currentPeriodEnd ?? null,\n\t\t\tdata.cancelAtPeriodEnd ?? false,\n\t\t\tdata.trialEndsAt ?? null,\n\t\t],\n\t);\n}\n","import type { IFonderieContext } from '@fonderie/core';\n\nimport type { SubscriberType } from './types';\n\nexport interface ISubscriber {\n\ttype: SubscriberType;\n\tid: string;\n}\n\n// Converts window strings like '1d', '30d', '1h' to milliseconds.\nexport function parseWindowMs(window: string): number {\n\tconst n = parseInt(window, 10);\n\tconst unit = window.slice(String(n).length);\n\tswitch (unit) {\n\t\tcase 'h':\n\t\t\treturn n * 3_600_000;\n\t\tcase 'd':\n\t\t\treturn n * 86_400_000;\n\t\tcase 'm':\n\t\t\treturn n * 60_000;\n\t\tdefault:\n\t\t\tthrow new Error(`Unknown window unit: '${unit}' in '${window}'`);\n\t}\n}\n\n// Resolves billing subscriber from request context.\n// Precedence: X-Workspace-ID header → ctx.workspace (set by withWorkspace) → ctx.user\nexport function resolveSubscriber(ctx: IFonderieContext): ISubscriber | null {\n\tconst wsFromHeader = ctx.request.headers.get('x-workspace-id');\n\n\tif (wsFromHeader) {\n\t\treturn {\n\t\t\ttype: 'workspace',\n\t\t\tid: wsFromHeader,\n\t\t};\n\t}\n\n\tif (ctx.workspace?.id) {\n\t\treturn {\n\t\t\ttype: 'workspace',\n\t\t\tid: ctx.workspace.id,\n\t\t};\n\t}\n\n\tif (ctx.user?.id) {\n\t\treturn {\n\t\t\ttype: 'user',\n\t\t\tid: ctx.user.id,\n\t\t};\n\t}\n\n\treturn null;\n}\n","import type { Middleware, ICourierMessage } from '@fonderie/core';\nimport { setApiResponse, HTTP } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IBillingConfig } from '../config';\nimport type { ICounterBackend } from '../backends/types';\nimport { MESSAGE_KEYS } from '../config';\nimport { getSubscription } from '../services/subscriptions';\nimport { buildBillingContext } from '../services/policy';\nimport { resolveSubscriber, parseWindowMs } from '../utils';\n\n// In-process de-dup: tracks which threshold notifications have fired this session.\n// Acceptable to lose on restart (may send one duplicate after a redeploy).\nconst notified = new Set<string>();\n\nexport function withBilling(\n\tstore: IStoreAdapter,\n\tconfig: IBillingConfig,\n\tbackend: ICounterBackend,\n): Middleware {\n\treturn async (ctx, next) => {\n\t\tconst subscriber = resolveSubscriber(ctx);\n\n\t\t// No subscriber (unauthenticated / public route) — skip entirely\n\t\tif (!subscriber) return next();\n\n\t\t// Resolve subscription → plan name (fall back to first plan = free)\n\t\tconst subscription = await getSubscription(subscriber.type, subscriber.id, store);\n\t\tconst planName = subscription?.plan ?? config.plans[0]?.name ?? 'free';\n\t\tconst active =\n\t\t\t!subscription || subscription.status === 'active' || subscription.status === 'trialing';\n\n\t\tconst plan = config.plans.find((p) => p.name === planName) ?? config.plans[0];\n\t\tif (!plan) return next();\n\n\t\t// Increment windowed (rate-limit) counters and read their current totals\n\t\tconst counters: Record<string, number> = {};\n\n\t\tfor (const [key, entry] of Object.entries(plan.policy ?? {})) {\n\t\t\tif ('enabled' in entry || !entry.window) continue;\n\n\t\t\tconst windowMs = parseWindowMs(entry.window);\n\t\t\tconst counterKey = `${subscriber.type}:${subscriber.id}:${key}`;\n\t\t\tcounters[key] = await backend.increment(counterKey, windowMs);\n\t\t}\n\n\t\t// Build and cache billing context on ctx\n\t\tconst billingCtx = buildBillingContext({ subscriber, plan, active, counters });\n\t\tctx.meta['billing'] = billingCtx;\n\n\t\t// Block requests that have hit a hard limit\n\t\tfor (const [key, status] of Object.entries(billingCtx.statuses)) {\n\t\t\tif (status.type === 'counter' && status.status === 'blocked') {\n\t\t\t\treturn setApiResponse(\n\t\t\t\t\tHTTP.TOO_MANY_REQUESTS,\n\t\t\t\t\t'RATE_LIMIT_EXCEEDED',\n\t\t\t\t\t`Limit exceeded for: ${key}`,\n\t\t\t\t\t{ key, limit: status.limit, used: status.used, resetsAt: status.resetsAt },\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\t// Fire threshold notifications (once per subscriber per key per session)\n\t\tif (config.notifications) {\n\t\t\tconst toNotify: ICourierMessage[] = [];\n\t\t\tconst recipient = {\n\t\t\t\temail: ctx.user?.email ?? null,\n\t\t\t\tphone: null,\n\t\t\t\tdeviceToken: null,\n\t\t\t};\n\n\t\t\tfor (const [key, status] of Object.entries(billingCtx.statuses)) {\n\t\t\t\tif (status.type !== 'counter' || status.limit === null) continue;\n\n\t\t\t\tconst base = `${subscriber.type}:${subscriber.id}:${key}`;\n\n\t\t\t\tif (config.notifications.softHit && status.status === 'over_limit') {\n\t\t\t\t\tconst nk = `${base}:reached`;\n\t\t\t\t\tif (!notified.has(nk)) {\n\t\t\t\t\t\tnotified.add(nk);\n\t\t\t\t\t\ttoNotify.push({\n\t\t\t\t\t\t\ttype: MESSAGE_KEYS.limitReached,\n\t\t\t\t\t\t\trecipient,\n\t\t\t\t\t\t\tdata: {\n\t\t\t\t\t\t\t\tkey,\n\t\t\t\t\t\t\t\tplan: plan.name,\n\t\t\t\t\t\t\t\tlimit: status.limit,\n\t\t\t\t\t\t\t\tused: status.used,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t} else if (config.notifications.warnAt && status.status === 'warning') {\n\t\t\t\t\tconst nk = `${base}:warning`;\n\t\t\t\t\tif (!notified.has(nk)) {\n\t\t\t\t\t\tnotified.add(nk);\n\t\t\t\t\t\ttoNotify.push({\n\t\t\t\t\t\t\ttype: MESSAGE_KEYS.limitWarning,\n\t\t\t\t\t\t\trecipient,\n\t\t\t\t\t\t\tdata: {\n\t\t\t\t\t\t\t\tkey,\n\t\t\t\t\t\t\t\tplan: plan.name,\n\t\t\t\t\t\t\t\tlimit: status.limit,\n\t\t\t\t\t\t\t\tused: status.used,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (toNotify.length > 0) {\n\t\t\t\tconst existing = ctx.meta['messages'] as ICourierMessage[] | undefined;\n\t\t\t\tctx.meta['messages'] = [...(existing ?? []), ...toNotify];\n\t\t\t}\n\t\t}\n\n\t\treturn next();\n\t};\n}\n","import type { IBillingProvider } from './providers/types';\nimport type { PolicyEntry } from './types';\nimport type { ICounterBackend } from './backends/types';\n\nexport interface IBillingPlanPrice {\n\t/** Stable Stripe Price lookup_key, e.g. \"pro_monthly\". Preferred reference. */\n\tlookupKey?: string;\n\t/** Stripe price id. Used for hydration and as a lookup_key fallback. */\n\tpriceId?: string;\n\t/**\n\t * Display amount in cents — the seed value written to fonderie_plans and the\n\t * fallback shown (flagged pricingStale) when hydration is off or Stripe is\n\t * unreachable. When hydration resolves a live price, the live amount wins.\n\t */\n\tamount?: number;\n}\n\n/**\n * Read-through pricing: amount/currency come from Stripe (source of truth) rather\n * than the duplicated `amount` above. Gated by `hydration` (kill-switch, §16.9).\n * See packages/billing/docs/pricing-hydration.md.\n */\nexport interface IBillingPricingConfig {\n\t/** Kill-switch. When false (default), serve the configured amount/USD directly. */\n\thydration?: boolean;\n\t/** Fresh-cache TTL. Default 300_000 (5m). */\n\tcacheTtlMs?: number;\n\t/** Serve last-cached price on a transient resolution miss (lookup_key transfer). Default 3_600_000 (1h). */\n\ttransferGraceMs?: number;\n\t/** Max age to serve stale price during a Stripe outage before giving up. Default 86_400_000 (24h). */\n\tmaxStaleMs?: number;\n}\n\nexport interface IBillingPlanDefaults {\n\twarnAt?: number; // default warnAt fraction (0–1) for counter policies\n\tbuffer?: number; // default buffer for counter policies\n}\n\nexport interface IBillingPlan {\n\tname: string;\n\tdescription?: string;\n\ttier?: number;\n\ttrialDays?: number;\n\tmonthly?: IBillingPlanPrice;\n\tyearly?: IBillingPlanPrice;\n\tdefaults?: IBillingPlanDefaults;\n\tpolicy?: Record<string, PolicyEntry>;\n\tmetadata?: Record<string, unknown>;\n}\n\nexport type RateLimitBackendConfig = 'memory' | 'db' | ICounterBackend;\n\nexport interface IBillingNotificationsConfig {\n\twarnAt?: boolean; // fire courier message when warnAt threshold crossed\n\tsoftHit?: boolean; // fire when soft limit crossed\n}\n\nexport interface IBillingConfig {\n\tprovider: IBillingProvider;\n\tplans: IBillingPlan[];\n\tsuccessUrl: string;\n\tcancelUrl: string;\n\twebhookSecret?: string;\n\trateLimit?: { backend?: RateLimitBackendConfig };\n\tnotifications?: IBillingNotificationsConfig;\n\tpricing?: IBillingPricingConfig;\n}\n\nexport const MESSAGE_KEYS = {\n\tlimitWarning: 'billing.limit-warning',\n\tlimitReached: 'billing.limit-reached',\n\tlimitBlocked: 'billing.limit-blocked',\n} as const;\n\nexport type BillingMessageKey = (typeof MESSAGE_KEYS)[keyof typeof MESSAGE_KEYS];\n","import type { IBillingPlan } from '../config';\nimport type { LimitStatus, IPolicyStatus, IBillingContext, SubscriberType } from '../types';\nimport { parseWindowMs } from '../utils';\n\nexport function buildBillingContext(opts: {\n\tsubscriber: { type: SubscriberType; id: string };\n\tplan: IBillingPlan;\n\tactive: boolean;\n\t// Pre-fetched windowed counter values keyed by policy key.\n\t// Non-windowed counter keys are absent (their used count is 0 — app manages those).\n\tcounters: Record<string, number>;\n}): IBillingContext {\n\tconst { subscriber, plan, active, counters } = opts;\n\tconst defaults = plan.defaults ?? {};\n\tconst statuses: Record<string, IPolicyStatus> = {};\n\n\tfor (const [key, entry] of Object.entries(plan.policy ?? {})) {\n\t\tif ('enabled' in entry) {\n\t\t\tstatuses[key] = { type: 'feature', enabled: entry.enabled };\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst { limit, buffer = defaults.buffer ?? 0, warnAt = defaults.warnAt ?? 0.8, window } = entry;\n\n\t\tconst used = counters[key] ?? 0;\n\t\tconst hardLimit = limit !== null ? limit + buffer : null;\n\n\t\tlet status: LimitStatus = 'ok';\n\t\tif (hardLimit !== null && used >= hardLimit) status = 'blocked';\n\t\telse if (limit !== null && used >= limit) status = 'over_limit';\n\t\telse if (limit !== null && used >= limit * warnAt) status = 'warning';\n\n\t\tlet resetsAt: string | null = null;\n\t\tif (window) {\n\t\t\tconst windowMs = parseWindowMs(window);\n\t\t\tconst windowStart = Math.floor(Date.now() / windowMs) * windowMs;\n\t\t\tresetsAt = new Date(windowStart + windowMs).toISOString();\n\t\t}\n\n\t\tstatuses[key] = { type: 'counter', limit, used, status, resetsAt };\n\t}\n\n\treturn { subscriber, plan: plan.name, active, statuses };\n}\n"],"mappings":"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","names":["setApiResponse","HTTP","setApiResponse","HTTP"]}
1
+ {"version":3,"sources":["../../src/middlewares/require-plan.ts","../../src/services/subscriptions.ts","../../src/utils.ts","../../src/middlewares/billing.ts","../../src/config.ts","../../src/services/membership.ts","../../src/services/policy.ts","../../src/services/wallet.ts"],"sourcesContent":["import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { getSubscription } from '../services/subscriptions';\nimport { resolveSubscriber } from '../utils';\n\n// Gates a route behind a minimum plan.\n// Works for both user-level and workspace-level subscriptions.\n// Usage: requirePlan(['pro', 'enterprise'], store)\n\nfunction makeHandler(plans: string | string[], store: IStoreAdapter): Middleware {\n\tconst allowed = Array.isArray(plans) ? plans : [plans];\n\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst subscriber = resolveSubscriber(ctx);\n\t\tif (!subscriber) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'SUBSCRIBER_REQUIRED', 'Subscriber context required');\n\t\t}\n\n\t\tconst subscription = await getSubscription(subscriber.type, subscriber.id, store);\n\n\t\tif (!subscription || !allowed.includes(subscription.plan)) {\n\t\t\treturn setApiResponse(\n\t\t\t\tHTTP.PAYMENT_REQUIRED,\n\t\t\t\t'PLAN_UPGRADE_REQUIRED',\n\t\t\t\t'Plan upgrade required',\n\t\t\t\t{ required: allowed, current: subscription?.plan ?? 'none' },\n\t\t\t);\n\t\t}\n\n\t\tif (subscription.status !== 'active' && subscription.status !== 'trialing') {\n\t\t\treturn setApiResponse(\n\t\t\t\tHTTP.PAYMENT_REQUIRED,\n\t\t\t\t'SUBSCRIPTION_INACTIVE',\n\t\t\t\t'Subscription is not active',\n\t\t\t\t{ status: subscription.status },\n\t\t\t);\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requirePlan(plans: string | string[], store: IStoreAdapter): Middleware;\nexport function requirePlan(\n\tplans: string | string[],\n\tstore: IStoreAdapter,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requirePlan(\n\tplans: string | string[],\n\tstore: IStoreAdapter,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(plans, store);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { BillingInterval, ISubscription, SubscriberType } from '../types';\n\nconst SELECT_SUBSCRIPTION = `\n\tSELECT\n\t\tid,\n\t\tsubscriber_type AS \"subscriberType\",\n\t\tsubscriber_id AS \"subscriberId\",\n\t\tplan,\n\t\tinterval,\n\t\tstatus,\n\t\tprovider_customer_id AS \"providerCustomerId\",\n\t\tprovider_subscription_id AS \"providerSubscriptionId\",\n\t\tcurrent_period_start AS \"currentPeriodStart\",\n\t\tcurrent_period_end AS \"currentPeriodEnd\",\n\t\tcancel_at_period_end AS \"cancelAtPeriodEnd\",\n\t\ttrial_ends_at AS \"trialEndsAt\",\n\t\tcreated_at AS \"createdAt\"\n\tFROM fonderie_subscriptions`;\n\nexport async function getSubscription(\n\tsubscriberType: SubscriberType,\n\tsubscriberId: string,\n\tstore: IStoreAdapter,\n): Promise<ISubscription | null> {\n\tconst [row] = await store.query<ISubscription>(\n\t\t`${SELECT_SUBSCRIPTION} WHERE subscriber_type = $1 AND subscriber_id = $2`,\n\t\t[subscriberType, subscriberId],\n\t);\n\treturn row ?? null;\n}\n\nexport async function upsertSubscription(\n\tdata: {\n\t\tsubscriberType: SubscriberType;\n\t\tsubscriberId: string;\n\t\tplan: string;\n\t\tinterval?: BillingInterval;\n\t\tstatus: string;\n\t\tproviderCustomerId?: string;\n\t\tproviderSubscriptionId?: string;\n\t\tcurrentPeriodStart?: Date;\n\t\tcurrentPeriodEnd?: Date;\n\t\tcancelAtPeriodEnd?: boolean;\n\t\ttrialEndsAt?: Date | null;\n\t},\n\tstore: IStoreAdapter,\n): Promise<void> {\n\tawait store.query(\n\t\t`INSERT INTO fonderie_subscriptions\n\t\t\t(subscriber_type, subscriber_id, plan, interval, status,\n\t\t\t provider_customer_id, provider_subscription_id,\n\t\t\t current_period_start, current_period_end,\n\t\t\t cancel_at_period_end, trial_ends_at)\n\t\t VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)\n\t\t ON CONFLICT (subscriber_type, subscriber_id) DO UPDATE SET\n\t\t\t plan = $3,\n\t\t\t interval = $4,\n\t\t\t status = $5,\n\t\t\t provider_customer_id = COALESCE($6, fonderie_subscriptions.provider_customer_id),\n\t\t\t provider_subscription_id = COALESCE($7, fonderie_subscriptions.provider_subscription_id),\n\t\t\t current_period_start = $8,\n\t\t\t current_period_end = $9,\n\t\t\t cancel_at_period_end = $10,\n\t\t\t trial_ends_at = $11`,\n\t\t[\n\t\t\tdata.subscriberType,\n\t\t\tdata.subscriberId,\n\t\t\tdata.plan,\n\t\t\tdata.interval ?? 'month',\n\t\t\tdata.status,\n\t\t\tdata.providerCustomerId ?? null,\n\t\t\tdata.providerSubscriptionId ?? null,\n\t\t\tdata.currentPeriodStart ?? null,\n\t\t\tdata.currentPeriodEnd ?? null,\n\t\t\tdata.cancelAtPeriodEnd ?? false,\n\t\t\tdata.trialEndsAt ?? null,\n\t\t],\n\t);\n}\n","import type { IFonderieContext } from '@fonderie/core';\n\nimport type { SubscriberType } from './types';\n\nexport interface ISubscriber {\n\ttype: SubscriberType;\n\tid: string;\n}\n\n// Narrow a bigint into a JS number, refusing values past 2^53 — loud failure\n// beats silent rounding. Used where a BOUNDED amount meets a number-typed\n// boundary (the wire-stable plan pricing DTO, the Stripe SDK). Deliberately\n// not named after money: wallet balances are unbounded and must stay bigint —\n// this is a narrowing tool, not a blessed money-to-number escape hatch.\nexport function toSafeNumber(amount: bigint): number {\n\tif (amount > BigInt(Number.MAX_SAFE_INTEGER) || amount < -BigInt(Number.MAX_SAFE_INTEGER)) {\n\t\tthrow new Error(`[billing] amount ${amount} exceeds Number.MAX_SAFE_INTEGER`);\n\t}\n\treturn Number(amount);\n}\n\n// One canonical form for wallet currency codes. Balances are keyed by the\n// literal string — a lowercase 'usd' or a padded 'USD ' would open a second,\n// unreachable bucket next to 'USD', so every boundary (config, schema, query\n// param, webhook metadata) normalizes through here. Trim + case only:\n// interior garbage ('U SD') is NOT repaired — write boundaries reject it\n// instead, because silently guessing at a money-bucket key hides caller bugs.\nexport function normalizeCurrency(currency: string): string {\n\treturn currency.trim().toUpperCase();\n}\n\n// Converts window strings like '1d', '30d', '1h' to milliseconds.\nexport function parseWindowMs(window: string): number {\n\tconst n = parseInt(window, 10);\n\tconst unit = window.slice(String(n).length);\n\tswitch (unit) {\n\t\tcase 'h':\n\t\t\treturn n * 3_600_000;\n\t\tcase 'd':\n\t\t\treturn n * 86_400_000;\n\t\tcase 'm':\n\t\t\treturn n * 60_000;\n\t\tdefault:\n\t\t\tthrow new Error(`Unknown window unit: '${unit}' in '${window}'`);\n\t}\n}\n\n// Resolves billing subscriber from request context.\n// Precedence: X-Workspace-ID header → ctx.workspace (set by withWorkspace) → ctx.user\nexport function resolveSubscriber(ctx: IFonderieContext): ISubscriber | null {\n\tconst wsFromHeader = ctx.request.headers.get('x-workspace-id');\n\n\tif (wsFromHeader) {\n\t\treturn {\n\t\t\ttype: 'workspace',\n\t\t\tid: wsFromHeader,\n\t\t};\n\t}\n\n\tif (ctx.workspace?.id) {\n\t\treturn {\n\t\t\ttype: 'workspace',\n\t\t\tid: ctx.workspace.id,\n\t\t};\n\t}\n\n\tif (ctx.user?.id) {\n\t\treturn {\n\t\t\ttype: 'user',\n\t\t\tid: ctx.user.id,\n\t\t};\n\t}\n\n\treturn null;\n}\n","import type { Middleware, ICourierMessage } from '@fonderie/core';\nimport { setApiResponse, HTTP } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IBillingConfig } from '../config';\nimport type { ICounterBackend } from '../backends/types';\nimport { MESSAGE_KEYS } from '../config';\nimport { getSubscription } from '../services/subscriptions';\nimport { isWorkspaceMember } from '../services/membership';\nimport { buildBillingContext } from '../services/policy';\nimport {\n\tcurrentGrantPeriod,\n\tensurePeriodicGrant,\n\tgetWalletBalance,\n\tresolvePlanWallet,\n} from '../services/wallet';\nimport { resolveSubscriber, parseWindowMs } from '../utils';\n\n// In-process de-dup: tracks which threshold notifications have fired this session.\n// Acceptable to lose on restart (may send one duplicate after a redeploy).\nconst notified = new Set<string>();\n\nexport function withBilling(\n\tstore: IStoreAdapter,\n\tconfig: IBillingConfig,\n\tbackend: ICounterBackend,\n): Middleware {\n\treturn async (ctx, next) => {\n\t\tconst subscriber = resolveSubscriber(ctx);\n\n\t\t// No subscriber (unauthenticated / public route) — skip entirely\n\t\tif (!subscriber) return next();\n\n\t\t// SECURITY — workspace subscribers can come from the raw X-Workspace-ID\n\t\t// header. Trust the id only when it matches ctx.workspace (already\n\t\t// membership-verified by @fonderie/workspaces' withWorkspace) or when\n\t\t// the session user proves active membership here. Anything else would\n\t\t// let any caller read, drain, or rate-limit another tenant's billing.\n\t\tif (subscriber.type === 'workspace' && ctx.workspace?.id !== subscriber.id) {\n\t\t\t// Anonymous request naming a workspace: no billing context at all —\n\t\t\t// public routes keep working, and an unverified workspace's counters\n\t\t\t// and wallet stay untouched.\n\t\t\tif (!ctx.user) return next();\n\t\t\tif (!(await isWorkspaceMember(ctx.user.id, subscriber.id, store))) {\n\t\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'Not a member of this workspace');\n\t\t\t}\n\t\t}\n\n\t\t// Resolve subscription → plan name (fall back to first plan = free)\n\t\tconst subscription = await getSubscription(subscriber.type, subscriber.id, store);\n\t\tconst planName = subscription?.plan ?? config.plans[0]?.name ?? 'free';\n\t\tconst active =\n\t\t\t!subscription || subscription.status === 'active' || subscription.status === 'trialing';\n\n\t\tconst plan = config.plans.find((p) => p.name === planName) ?? config.plans[0];\n\t\tif (!plan) return next();\n\n\t\t// Increment windowed (rate-limit) counters and read their current totals\n\t\tconst counters: Record<string, number> = {};\n\n\t\tfor (const [key, entry] of Object.entries(plan.policy ?? {})) {\n\t\t\tif ('enabled' in entry || !entry.window) continue;\n\n\t\t\tconst windowMs = parseWindowMs(entry.window);\n\t\t\tconst counterKey = `${subscriber.type}:${subscriber.id}:${key}`;\n\t\t\tcounters[key] = await backend.increment(counterKey, windowMs);\n\t\t}\n\n\t\t// Build and cache billing context on ctx\n\t\tconst billingCtx = buildBillingContext({ subscriber, plan, active, counters });\n\t\tctx.meta['billing'] = billingCtx;\n\n\t\t// Wallet economics — lazy periodic grant, then a balance snapshot for\n\t\t// requireWalletBalance and product code. Non-fatal by design: a wallet\n\t\t// hiccup must not take down unrelated requests.\n\t\tconst planWallet = resolvePlanWallet(plan, config);\n\t\tif (planWallet) {\n\t\t\ttry {\n\t\t\t\tconst sub = {\n\t\t\t\t\tsubscriberType: subscriber.type,\n\t\t\t\t\tsubscriberId: subscriber.id,\n\t\t\t\t\tcurrency: planWallet.currency,\n\t\t\t\t};\n\t\t\t\t// Grants require an active (or trialing) subscription — a past_due\n\t\t\t\t// or paused subscriber keeps spending existing credits but is not\n\t\t\t\t// extended new ones while payment is failing.\n\t\t\t\tif (active && planWallet.grantAmount !== null && planWallet.grantAmount > 0n) {\n\t\t\t\t\tawait ensurePeriodicGrant(\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t...sub,\n\t\t\t\t\t\t\tamount: planWallet.grantAmount,\n\t\t\t\t\t\t\tperiod: currentGrantPeriod(planWallet.grantPeriod),\n\t\t\t\t\t\t},\n\t\t\t\t\t\tstore,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tconst { balance } = await getWalletBalance(sub, store);\n\t\t\t\tbillingCtx.wallet = {\n\t\t\t\t\tbalance,\n\t\t\t\t\tcurrency: planWallet.currency,\n\t\t\t\t\tprecision: planWallet.precision,\n\t\t\t\t\toverdraftLimit: planWallet.overdraftLimit,\n\t\t\t\t\trates: planWallet.rates,\n\t\t\t\t};\n\t\t\t} catch (err) {\n\t\t\t\t// eslint-disable-next-line no-console\n\t\t\t\tconsole.error('[billing] wallet context failed:', (err as Error).message);\n\t\t\t}\n\t\t}\n\n\t\t// Block requests that have hit a hard limit\n\t\tfor (const [key, status] of Object.entries(billingCtx.statuses)) {\n\t\t\tif (status.type === 'counter' && status.status === 'blocked') {\n\t\t\t\treturn setApiResponse(\n\t\t\t\t\tHTTP.TOO_MANY_REQUESTS,\n\t\t\t\t\t'RATE_LIMIT_EXCEEDED',\n\t\t\t\t\t`Limit exceeded for: ${key}`,\n\t\t\t\t\t{ key, limit: status.limit, used: status.used, resetsAt: status.resetsAt },\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\t// Fire threshold notifications (once per subscriber per key per session)\n\t\tif (config.notifications) {\n\t\t\tconst toNotify: ICourierMessage[] = [];\n\t\t\tconst recipient = {\n\t\t\t\temail: ctx.user?.email ?? null,\n\t\t\t\tphone: null,\n\t\t\t\tdeviceToken: null,\n\t\t\t};\n\n\t\t\tfor (const [key, status] of Object.entries(billingCtx.statuses)) {\n\t\t\t\tif (status.type !== 'counter' || status.limit === null) continue;\n\n\t\t\t\tconst base = `${subscriber.type}:${subscriber.id}:${key}`;\n\n\t\t\t\tif (config.notifications.softHit && status.status === 'over_limit') {\n\t\t\t\t\tconst nk = `${base}:reached`;\n\t\t\t\t\tif (!notified.has(nk)) {\n\t\t\t\t\t\tnotified.add(nk);\n\t\t\t\t\t\ttoNotify.push({\n\t\t\t\t\t\t\ttype: MESSAGE_KEYS.limitReached,\n\t\t\t\t\t\t\trecipient,\n\t\t\t\t\t\t\tdata: {\n\t\t\t\t\t\t\t\tkey,\n\t\t\t\t\t\t\t\tplan: plan.name,\n\t\t\t\t\t\t\t\tlimit: status.limit,\n\t\t\t\t\t\t\t\tused: status.used,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t} else if (config.notifications.warnAt && status.status === 'warning') {\n\t\t\t\t\tconst nk = `${base}:warning`;\n\t\t\t\t\tif (!notified.has(nk)) {\n\t\t\t\t\t\tnotified.add(nk);\n\t\t\t\t\t\ttoNotify.push({\n\t\t\t\t\t\t\ttype: MESSAGE_KEYS.limitWarning,\n\t\t\t\t\t\t\trecipient,\n\t\t\t\t\t\t\tdata: {\n\t\t\t\t\t\t\t\tkey,\n\t\t\t\t\t\t\t\tplan: plan.name,\n\t\t\t\t\t\t\t\tlimit: status.limit,\n\t\t\t\t\t\t\t\tused: status.used,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (toNotify.length > 0) {\n\t\t\t\tconst existing = ctx.meta['messages'] as ICourierMessage[] | undefined;\n\t\t\t\tctx.meta['messages'] = [...(existing ?? []), ...toNotify];\n\t\t\t}\n\t\t}\n\n\t\treturn next();\n\t};\n}\n","import type { IBillingProvider } from './providers/types';\nimport type { IWalletRate, PolicyEntry } from './types';\nimport type { ICounterBackend } from './backends/types';\n\nexport interface IBillingPlanPrice {\n\t/** Stable Stripe Price lookup_key, e.g. \"pro_monthly\". Preferred reference. */\n\tlookupKey?: string;\n\t/** Stripe price id. Used for hydration and as a lookup_key fallback. */\n\tpriceId?: string;\n\t/**\n\t * Display amount in the smallest currency unit (bigint, e.g. 1999n = $19.99) —\n\t * the seed value written to fonderie_plans and the fallback shown (flagged\n\t * pricingStale) when hydration is off or Stripe is unreachable. When\n\t * hydration resolves a live price, the live amount wins.\n\t */\n\tamount?: bigint;\n}\n\n/**\n * Read-through pricing: amount/currency come from Stripe (source of truth) rather\n * than the duplicated `amount` above. Gated by `hydration` (kill-switch, §16.9).\n * See packages/billing/docs/pricing-hydration.md.\n */\nexport interface IBillingPricingConfig {\n\t/** Kill-switch. When false (default), serve the configured amount/USD directly. */\n\thydration?: boolean;\n\t/** Fresh-cache TTL. Default 300_000 (5m). */\n\tcacheTtlMs?: number;\n\t/** Serve last-cached price on a transient resolution miss (lookup_key transfer). Default 3_600_000 (1h). */\n\ttransferGraceMs?: number;\n\t/** Max age to serve stale price during a Stripe outage before giving up. Default 86_400_000 (24h). */\n\tmaxStaleMs?: number;\n}\n\nexport interface IBillingPlanDefaults {\n\twarnAt?: number; // default warnAt fraction (0–1) for counter policies\n\tbuffer?: number; // default buffer for counter policies\n}\n\n/**\n * Per-plan wallet economics. Requires config.wallet to be set — a plan-level\n * wallet without the global opt-in is ignored (with a boot warning).\n */\nexport interface IBillingPlanWallet {\n\t/** Overrides the global wallet currency for this plan's grants and rates. */\n\tcurrency?: string;\n\t/** Display precision override. */\n\tprecision?: number;\n\t/**\n\t * Credits auto-granted once per grantPeriod, applied lazily by withBilling\n\t * on the subscriber's first request of the period. Only granted while the\n\t * subscription is active or trialing (no new credit while payment fails).\n\t */\n\tgrantAmount?: bigint;\n\t/** Grant cadence for grantAmount. Default 'month'. */\n\tgrantPeriod?: 'month' | 'week' | 'day';\n\t/** How far below zero rate debits may take the balance. Default 0n (block at zero). */\n\toverdraftLimit?: bigint;\n\t/** Per-metric unit costs, e.g. { 'sms:send': { cost: 75n, unit: 'msg' } }. */\n\trates?: Record<string, IWalletRate>;\n}\n\nexport interface IBillingPlan {\n\tname: string;\n\tdescription?: string;\n\ttier?: number;\n\ttrialDays?: number;\n\tmonthly?: IBillingPlanPrice;\n\tyearly?: IBillingPlanPrice;\n\tdefaults?: IBillingPlanDefaults;\n\tpolicy?: Record<string, PolicyEntry>;\n\twallet?: IBillingPlanWallet;\n\tmetadata?: Record<string, unknown>;\n}\n\nexport type RateLimitBackendConfig = 'memory' | 'db' | ICounterBackend;\n\nexport interface IBillingNotificationsConfig {\n\twarnAt?: boolean; // fire courier message when warnAt threshold crossed\n\tsoftHit?: boolean; // fire when soft limit crossed\n}\n\n/**\n * A purchasable credit top-up, synced to fonderie_credit_packs at boot (same\n * pattern as plans). Purchases go through the provider's one-time checkout;\n * the payment webhook credits `credits` to the buyer's wallet.\n */\nexport interface IBillingCreditPack {\n\t/** Stable identifier used by POST /billing/wallet/checkout, e.g. 'small'. */\n\tid: string;\n\tname: string;\n\t/** Wallet credits granted on purchase, in the smallest wallet unit. */\n\tcredits: bigint;\n\t/** Purchase price in the provider's smallest currency unit. */\n\tpriceAmount: bigint;\n\t/**\n\t * ISO 4217 PAYMENT currency for the provider charge; defaults to the\n\t * buyer's wallet currency. Credits always land in the buyer's wallet\n\t * currency regardless of what the charge was priced in.\n\t */\n\tcurrency?: string;\n\t/** Existing provider Price id — used instead of the ad-hoc priceAmount. */\n\tpriceId?: string;\n\t/** Inactive packs stay in the DB but can no longer be checked out. */\n\tactive?: boolean;\n\tmetadata?: Record<string, unknown>;\n}\n\n/**\n * Opt-in stored-value wallet. Presence of this object activates the wallet\n * subsystem (routes, credit packs, per-plan grants and rates); leaving it out\n * changes nothing for existing subscription-only consumers.\n */\nexport interface IBillingWalletConfig {\n\t/** Default wallet currency when a plan doesn't override it. Default 'USD'. */\n\tcurrency?: string;\n\t/** Display precision — decimal places of the smallest unit. Default 2. */\n\tprecision?: number;\n\t/**\n\t * Bearer token guarding POST /billing/wallet/grant (manual support/ops\n\t * grants). The route is only registered when a token is configured.\n\t */\n\tadminToken?: string;\n\t/**\n\t * Signing secret for POST /billing/webhook/payment. REQUIRED for pack\n\t * purchases: the route answers 500 until it is set, and it deliberately\n\t * does NOT fall back to the subscription webhook's secret — per-endpoint\n\t * secrets keep a delivery captured for one endpoint from replaying\n\t * against the other.\n\t */\n\twebhookSecret?: string;\n\tcreditPacks?: IBillingCreditPack[];\n}\n\nexport interface IBillingConfig {\n\tprovider: IBillingProvider;\n\tplans: IBillingPlan[];\n\tsuccessUrl: string;\n\tcancelUrl: string;\n\twebhookSecret?: string;\n\trateLimit?: { backend?: RateLimitBackendConfig };\n\tnotifications?: IBillingNotificationsConfig;\n\tpricing?: IBillingPricingConfig;\n\twallet?: IBillingWalletConfig;\n}\n\nexport const MESSAGE_KEYS = {\n\tlimitWarning: 'billing.limit-warning',\n\tlimitReached: 'billing.limit-reached',\n\tlimitBlocked: 'billing.limit-blocked',\n} as const;\n\nexport type BillingMessageKey = (typeof MESSAGE_KEYS)[keyof typeof MESSAGE_KEYS];\n","import type { IStoreAdapter } from '@fonderie/store';\n\n// Workspace-membership verification for header-derived subscribers.\n//\n// This is a deliberate cross-module DATA dependency (billing reads the\n// workspaces module's membership table) rather than a code import — modules\n// never import each other. The predicate mirrors @fonderie/workspaces'\n// getMember exactly: an active member is a role_user_workspaces row that is\n// neither removed nor suspended.\n//\n// Fail closed: when the query errors (e.g. the workspaces module — and thus\n// its table — is not installed), the caller is NOT a member. Apps without\n// workspaces never send X-Workspace-ID legitimately, so nothing breaks; an\n// attacker probing with the header gets a 403 instead of a wallet.\nexport async function isWorkspaceMember(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\ttry {\n\t\tconst rows = await store.query<{ ok: number }>(\n\t\t\t`SELECT 1 AS ok\n\t\t\t FROM fonderie_role_user_workspaces\n\t\t\t WHERE user_id = $1\n\t\t\t AND workspace_id = $2\n\t\t\t AND removed = false\n\t\t\t AND suspended = false\n\t\t\t LIMIT 1`,\n\t\t\t[userId, workspaceId],\n\t\t);\n\t\treturn rows.length > 0;\n\t} catch {\n\t\treturn false;\n\t}\n}\n","import type { IBillingPlan } from '../config';\nimport type { LimitStatus, IPolicyStatus, IBillingContext, SubscriberType } from '../types';\nimport { parseWindowMs } from '../utils';\n\nexport function buildBillingContext(opts: {\n\tsubscriber: { type: SubscriberType; id: string };\n\tplan: IBillingPlan;\n\tactive: boolean;\n\t// Pre-fetched windowed counter values keyed by policy key.\n\t// Non-windowed counter keys are absent (their used count is 0 — app manages those).\n\tcounters: Record<string, number>;\n}): IBillingContext {\n\tconst { subscriber, plan, active, counters } = opts;\n\tconst defaults = plan.defaults ?? {};\n\tconst statuses: Record<string, IPolicyStatus> = {};\n\n\tfor (const [key, entry] of Object.entries(plan.policy ?? {})) {\n\t\tif ('enabled' in entry) {\n\t\t\tstatuses[key] = { type: 'feature', enabled: entry.enabled };\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst { limit, buffer = defaults.buffer ?? 0, warnAt = defaults.warnAt ?? 0.8, window } = entry;\n\n\t\tconst used = counters[key] ?? 0;\n\t\tconst hardLimit = limit !== null ? limit + buffer : null;\n\n\t\tlet status: LimitStatus = 'ok';\n\t\tif (hardLimit !== null && used >= hardLimit) status = 'blocked';\n\t\telse if (limit !== null && used >= limit) status = 'over_limit';\n\t\telse if (limit !== null && used >= limit * warnAt) status = 'warning';\n\n\t\tlet resetsAt: string | null = null;\n\t\tif (window) {\n\t\t\tconst windowMs = parseWindowMs(window);\n\t\t\tconst windowStart = Math.floor(Date.now() / windowMs) * windowMs;\n\t\t\tresetsAt = new Date(windowStart + windowMs).toISOString();\n\t\t}\n\n\t\tstatuses[key] = { type: 'counter', limit, used, status, resetsAt };\n\t}\n\n\treturn { subscriber, plan: plan.name, active, statuses };\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type {\n\tIWalletBalance,\n\tIWalletLedgerEntry,\n\tIWalletRate,\n\tSubscriberType,\n\tWalletLedgerType,\n} from '../types';\nimport type { IBillingConfig, IBillingPlan } from '../config';\nimport { DuplicateTransactionError, InsufficientFundsError } from '../errors';\nimport { normalizeCurrency } from '../utils';\n\n// The ledger is the source of truth; fonderie_wallet_balances is a cache that\n// is NEVER written without a ledger row in the same transaction. Every\n// mutation carries an idempotency key backed by the ledger's UNIQUE\n// constraint, so a replayed request re-reads instead of re-applying: the\n// pre-check catches replays cheaply, and a lost race between two identical\n// replays still resolves safely — the second ledger INSERT violates the\n// constraint and rolls its balance write back with it.\n//\n// CONTRACT: pass the module-level store, never a tx-scoped adapter from an\n// enclosing store.transaction. The wallet manages its own transaction; the\n// pg adapter flattens nested transactions WITHOUT savepoints, so inside a\n// caller's transaction the rollback-on-conflict guarantee above would not\n// hold (and a conflict would poison the caller's whole transaction).\n\nexport interface IWalletSubscriber {\n\tsubscriberType: SubscriberType;\n\tsubscriberId: string;\n\tcurrency: string;\n}\n\nexport interface IWalletMutationResult {\n\tbalance: bigint;\n\t// True when the idempotency key had already been applied — the wallet was\n\t// left untouched and `balance` is the current value.\n\tduplicate: boolean;\n}\n\ninterface ILedgerKeyRow {\n\tsubscriberType: SubscriberType;\n\tsubscriberId: string;\n\tcurrency: string;\n}\n\ninterface IBalanceRow {\n\tamount: string;\n\tversion: string;\n\tupdatedAt: string | Date | null;\n}\n\nconst UNIQUE_VIOLATION = '23505';\n\n// Only the ledger's idempotency-key UNIQUE violation counts as a safe\n// replay. Anything else (NOT NULL violations, other constraints) must\n// surface as the error it is — a loose message match here once turned a\n// rolled-back failure into a fake duplicate-success.\nfunction isIdempotencyConflict(err: unknown): boolean {\n\tconst e = err as { code?: string; constraint?: string; message?: string };\n\tif (e?.code !== UNIQUE_VIOLATION) return false;\n\tif (typeof e.constraint === 'string') return e.constraint.includes('idempotency_key');\n\treturn typeof e.message === 'string' && e.message.includes('idempotency_key');\n}\n\n// Returns the existing ledger row for the key, or null. Throws when the key\n// exists but belongs to a different subscriber/currency — key reuse across\n// scopes is a caller bug, not a safe replay.\nasync function findByIdempotencyKey(\n\tsub: IWalletSubscriber,\n\tidempotencyKey: string,\n\tstore: IStoreAdapter,\n): Promise<ILedgerKeyRow | null> {\n\tconst [row] = await store.query<ILedgerKeyRow>(\n\t\t`SELECT\n\t\t\tsubscriber_type AS \"subscriberType\",\n\t\t\tsubscriber_id AS \"subscriberId\",\n\t\t\tcurrency\n\t\tFROM fonderie_wallet_ledger\n\t\tWHERE idempotency_key = $1`,\n\t\t[idempotencyKey],\n\t);\n\tif (!row) return null;\n\tif (\n\t\trow.subscriberType !== sub.subscriberType ||\n\t\trow.subscriberId !== sub.subscriberId ||\n\t\trow.currency !== sub.currency\n\t) {\n\t\tthrow new DuplicateTransactionError(idempotencyKey);\n\t}\n\treturn row;\n}\n\nasync function readBalance(sub: IWalletSubscriber, store: IStoreAdapter): Promise<bigint> {\n\tconst [row] = await store.query<{ amount: string }>(\n\t\t`SELECT amount FROM fonderie_wallet_balances\n\t\tWHERE subscriber_type = $1 AND subscriber_id = $2 AND currency = $3`,\n\t\t[sub.subscriberType, sub.subscriberId, sub.currency],\n\t);\n\treturn BigInt(row?.amount ?? '0');\n}\n\n// Atomic upsert-add on the balance cache. tx-scoped: callers pair it with a\n// ledger row in the same transaction, never alone.\nasync function applyBalanceCredit(\n\ttx: IStoreAdapter,\n\tsub: IWalletSubscriber,\n\tamount: bigint,\n): Promise<bigint> {\n\tconst [row] = await tx.query<{ amount: string }>(\n\t\t`INSERT INTO fonderie_wallet_balances (subscriber_type, subscriber_id, currency, amount)\n\t\tVALUES ($1, $2, $3, $4)\n\t\tON CONFLICT (subscriber_type, subscriber_id, currency) DO UPDATE SET\n\t\t\tamount = fonderie_wallet_balances.amount + EXCLUDED.amount,\n\t\t\tversion = fonderie_wallet_balances.version + 1,\n\t\t\tupdated_at = now()\n\t\tRETURNING amount`,\n\t\t[sub.subscriberType, sub.subscriberId, sub.currency, amount.toString()],\n\t);\n\treturn BigInt(row?.amount ?? '0');\n}\n\nasync function insertLedgerRow(\n\ttx: IStoreAdapter,\n\tsub: IWalletSubscriber,\n\topts: {\n\t\ttype: WalletLedgerType;\n\t\tamount: bigint; // signed\n\t\tbalanceAfter: bigint;\n\t\tidempotencyKey: string;\n\t\tdescription: string | null;\n\t\tmetadata: Record<string, unknown>;\n\t\tproviderTxId: string | null;\n\t},\n): Promise<void> {\n\tawait tx.query(\n\t\t`INSERT INTO fonderie_wallet_ledger\n\t\t\t(subscriber_type, subscriber_id, currency, type, amount, balance_after,\n\t\t\t description, idempotency_key, metadata, provider_tx_id)\n\t\tVALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)`,\n\t\t[\n\t\t\tsub.subscriberType,\n\t\t\tsub.subscriberId,\n\t\t\tsub.currency,\n\t\t\topts.type,\n\t\t\topts.amount.toString(),\n\t\t\topts.balanceAfter.toString(),\n\t\t\topts.description,\n\t\t\topts.idempotencyKey,\n\t\t\tJSON.stringify(opts.metadata),\n\t\t\topts.providerTxId,\n\t\t],\n\t);\n}\n\n// Add credits. Idempotent: a replayed key returns the current balance with\n// duplicate: true. The balance upsert-add is a single atomic statement, so\n// credits need no row lock; the same-transaction ledger row (with its UNIQUE\n// key) is what makes a concurrent identical replay roll back cleanly.\n// Pass the module-level store — never a tx-scoped adapter (see file header).\nexport async function creditWallet(\n\topts: IWalletSubscriber & {\n\t\tamount: bigint; // positive\n\t\tidempotencyKey: string;\n\t\ttype?: WalletLedgerType;\n\t\tdescription?: string;\n\t\tmetadata?: Record<string, unknown>;\n\t\tproviderTxId?: string;\n\t},\n\tstore: IStoreAdapter,\n): Promise<IWalletMutationResult> {\n\tif (opts.amount < 0n) throw new Error('[billing:wallet] credit amount must be positive');\n\tif (!opts.idempotencyKey) throw new Error('[billing:wallet] idempotencyKey is required');\n\tif (opts.amount === 0n) {\n\t\treturn { balance: await readBalance(opts, store), duplicate: false };\n\t}\n\n\ttry {\n\t\treturn await store.transaction(async (tx) => {\n\t\t\tconst existing = await findByIdempotencyKey(opts, opts.idempotencyKey, tx);\n\t\t\tif (existing) return { balance: await readBalance(opts, tx), duplicate: true };\n\n\t\t\tconst balance = await applyBalanceCredit(tx, opts, opts.amount);\n\n\t\t\tawait insertLedgerRow(tx, opts, {\n\t\t\t\ttype: opts.type ?? 'adjustment',\n\t\t\t\tamount: opts.amount,\n\t\t\t\tbalanceAfter: balance,\n\t\t\t\tidempotencyKey: opts.idempotencyKey,\n\t\t\t\tdescription: opts.description ?? null,\n\t\t\t\tmetadata: opts.metadata ?? {},\n\t\t\t\tproviderTxId: opts.providerTxId ?? null,\n\t\t\t});\n\n\t\t\treturn { balance, duplicate: false };\n\t\t});\n\t} catch (err) {\n\t\t// Lost a race against an identical replay: its ledger row landed first,\n\t\t// ours violated the UNIQUE key and the whole transaction rolled back.\n\t\tif (isIdempotencyConflict(err)) {\n\t\t\treturn { balance: await readBalance(opts, store), duplicate: true };\n\t\t}\n\t\tthrow err;\n\t}\n}\n\n// Atomically deduct credits. Two independent guarantees prevent double-spend:\n// the SELECT ... FOR UPDATE serializes concurrent debits for one subscriber,\n// and the conditional UPDATE (amount - cost >= floor) re-checks the floor in\n// the same statement — so even a backend without row locks cannot go below\n// the overdraft floor. Throws InsufficientFundsError past the floor.\n// Pass the module-level store — never a tx-scoped adapter (see file header).\nexport async function debitWallet(\n\topts: IWalletSubscriber & {\n\t\tamount: bigint; // positive; recorded as negative in the ledger\n\t\tidempotencyKey: string;\n\t\ttype?: WalletLedgerType;\n\t\toverdraftLimit?: bigint; // >= 0; how far below zero the balance may go\n\t\tdescription?: string;\n\t\tmetadata?: Record<string, unknown>;\n\t},\n\tstore: IStoreAdapter,\n): Promise<IWalletMutationResult> {\n\tif (opts.amount < 0n) throw new Error('[billing:wallet] debit amount must be positive');\n\tif (!opts.idempotencyKey) throw new Error('[billing:wallet] idempotencyKey is required');\n\tif (opts.amount === 0n) {\n\t\t// Zero-cost debit (e.g. unlimited plan rate) — no ledger row, no-op.\n\t\treturn { balance: await readBalance(opts, store), duplicate: false };\n\t}\n\tconst floor = -(opts.overdraftLimit ?? 0n);\n\n\ttry {\n\t\treturn await store.transaction(async (tx) => {\n\t\t\tconst existing = await findByIdempotencyKey(opts, opts.idempotencyKey, tx);\n\t\t\tif (existing) return { balance: await readBalance(opts, tx), duplicate: true };\n\n\t\t\t// Make sure the row exists so FOR UPDATE has something to lock, then\n\t\t\t// lock it — concurrent debits for this subscriber serialize here.\n\t\t\tawait tx.query(\n\t\t\t\t`INSERT INTO fonderie_wallet_balances (subscriber_type, subscriber_id, currency, amount)\n\t\t\t\tVALUES ($1, $2, $3, 0)\n\t\t\t\tON CONFLICT (subscriber_type, subscriber_id, currency) DO NOTHING`,\n\t\t\t\t[opts.subscriberType, opts.subscriberId, opts.currency],\n\t\t\t);\n\t\t\tconst [locked] = await tx.query<{ amount: string }>(\n\t\t\t\t`SELECT amount FROM fonderie_wallet_balances\n\t\t\t\tWHERE subscriber_type = $1 AND subscriber_id = $2 AND currency = $3\n\t\t\t\tFOR UPDATE`,\n\t\t\t\t[opts.subscriberType, opts.subscriberId, opts.currency],\n\t\t\t);\n\t\t\tconst current = BigInt(locked?.amount ?? '0');\n\n\t\t\tif (current - opts.amount < floor) {\n\t\t\t\tthrow new InsufficientFundsError(current, opts.amount, opts.currency);\n\t\t\t}\n\n\t\t\tconst [updated] = await tx.query<{ amount: string }>(\n\t\t\t\t`UPDATE fonderie_wallet_balances\n\t\t\t\tSET amount = amount - $4, version = version + 1, updated_at = now()\n\t\t\t\tWHERE subscriber_type = $1 AND subscriber_id = $2 AND currency = $3\n\t\t\t\t\tAND amount - $4 >= $5\n\t\t\t\tRETURNING amount`,\n\t\t\t\t[\n\t\t\t\t\topts.subscriberType,\n\t\t\t\t\topts.subscriberId,\n\t\t\t\t\topts.currency,\n\t\t\t\t\topts.amount.toString(),\n\t\t\t\t\tfloor.toString(),\n\t\t\t\t],\n\t\t\t);\n\t\t\t// Belt and braces: with row locking this cannot miss after the check\n\t\t\t// above; without it, this is the statement that holds the floor.\n\t\t\tif (!updated) {\n\t\t\t\tthrow new InsufficientFundsError(current, opts.amount, opts.currency);\n\t\t\t}\n\t\t\tconst balance = BigInt(updated.amount);\n\n\t\t\tawait insertLedgerRow(tx, opts, {\n\t\t\t\ttype: opts.type ?? 'usage',\n\t\t\t\tamount: -opts.amount,\n\t\t\t\tbalanceAfter: balance,\n\t\t\t\tidempotencyKey: opts.idempotencyKey,\n\t\t\t\tdescription: opts.description ?? null,\n\t\t\t\tmetadata: opts.metadata ?? {},\n\t\t\t\tproviderTxId: null,\n\t\t\t});\n\n\t\t\treturn { balance, duplicate: false };\n\t\t});\n\t} catch (err) {\n\t\tif (isIdempotencyConflict(err)) {\n\t\t\treturn { balance: await readBalance(opts, store), duplicate: true };\n\t\t}\n\t\tthrow err;\n\t}\n}\n\nexport async function getWalletBalance(\n\tsub: IWalletSubscriber,\n\tstore: IStoreAdapter,\n): Promise<IWalletBalance> {\n\tconst [row] = await store.query<IBalanceRow>(\n\t\t`SELECT amount, version, updated_at AS \"updatedAt\"\n\t\tFROM fonderie_wallet_balances\n\t\tWHERE subscriber_type = $1 AND subscriber_id = $2 AND currency = $3`,\n\t\t[sub.subscriberType, sub.subscriberId, sub.currency],\n\t);\n\tif (!row) return { balance: 0n, version: 0, updatedAt: null };\n\treturn {\n\t\tbalance: BigInt(row.amount),\n\t\tversion: Number(row.version),\n\t\tupdatedAt: row.updatedAt ? new Date(row.updatedAt).toISOString() : null,\n\t};\n}\n\nexport interface IWalletLedgerPage {\n\tentries: IWalletLedgerEntry[];\n\t// Opaque cursor for the next (older) page, or null when exhausted.\n\tnextCursor: string | null;\n}\n\nexport function encodeLedgerCursor(createdAt: string, id: string): string {\n\treturn Buffer.from(JSON.stringify([createdAt, id])).toString('base64url');\n}\n\n// Accepts ISO timestamps and Postgres' own text format (microsecond\n// precision, e.g. '2026-09-04 18:50:50.888123+00') — the cursor carries the\n// latter to avoid the JS Date millisecond truncation that would skip\n// same-millisecond ledger rows between pages.\nconst CURSOR_TS_RE = /^\\d{4}-\\d{2}-\\d{2}[T ]\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,6})?(Z|[+-]\\d{2}(:?\\d{2})?)?$/;\nconst CURSOR_ID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;\n\nexport function decodeLedgerCursor(cursor: string): { createdAt: string; id: string } | null {\n\tif (cursor.length > 256) return null;\n\ttry {\n\t\tconst parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));\n\t\tif (!Array.isArray(parsed) || typeof parsed[0] !== 'string' || typeof parsed[1] !== 'string') {\n\t\t\treturn null;\n\t\t}\n\t\t// Both halves feed ::timestamptz / ::uuid casts — validate here so a\n\t\t// crafted cursor yields a 422, not a Postgres cast error.\n\t\tif (!CURSOR_TS_RE.test(parsed[0]) || !CURSOR_ID_RE.test(parsed[1])) return null;\n\t\treturn { createdAt: parsed[0], id: parsed[1] };\n\t} catch {\n\t\treturn null;\n\t}\n}\n\ninterface ILedgerRow {\n\tid: string;\n\tsubscriberType: SubscriberType;\n\tsubscriberId: string;\n\tcurrency: string;\n\ttype: WalletLedgerType;\n\tamount: string;\n\tbalanceAfter: string;\n\tdescription: string | null;\n\tidempotencyKey: string;\n\tmetadata: Record<string, unknown> | null;\n\tproviderTxId: string | null;\n\tcreatedAt: string | Date;\n\t// created_at::text — full microsecond precision for the keyset cursor\n\t// (node-pg parses timestamptz into a millisecond Date, which would make\n\t// the cursor skip rows sharing a truncated millisecond).\n\tcreatedAtRaw: string;\n}\n\nexport async function getWalletLedger(\n\topts: IWalletSubscriber & {\n\t\tlimit?: number; // 1..100, default 50\n\t\tcursor?: { createdAt: string; id: string };\n\t},\n\tstore: IStoreAdapter,\n): Promise<IWalletLedgerPage> {\n\tconst limit = Math.min(Math.max(opts.limit ?? 50, 1), 100);\n\n\tconst params: unknown[] = [opts.subscriberType, opts.subscriberId, opts.currency];\n\tlet cursorClause = '';\n\tif (opts.cursor) {\n\t\tparams.push(opts.cursor.createdAt, opts.cursor.id);\n\t\tcursorClause = `AND (created_at, id) < ($4::timestamptz, $5::uuid)`;\n\t}\n\tparams.push(limit + 1);\n\n\tconst rows = await store.query<ILedgerRow>(\n\t\t`SELECT\n\t\t\tid,\n\t\t\tsubscriber_type AS \"subscriberType\",\n\t\t\tsubscriber_id AS \"subscriberId\",\n\t\t\tcurrency,\n\t\t\ttype,\n\t\t\tamount,\n\t\t\tbalance_after AS \"balanceAfter\",\n\t\t\tdescription,\n\t\t\tidempotency_key AS \"idempotencyKey\",\n\t\t\tmetadata,\n\t\t\tprovider_tx_id AS \"providerTxId\",\n\t\t\tcreated_at AS \"createdAt\",\n\t\t\tcreated_at::text AS \"createdAtRaw\"\n\t\tFROM fonderie_wallet_ledger\n\t\tWHERE subscriber_type = $1 AND subscriber_id = $2 AND currency = $3\n\t\t\t${cursorClause}\n\t\tORDER BY created_at DESC, id DESC\n\t\tLIMIT $${params.length}`,\n\t\tparams,\n\t);\n\n\tconst page = rows.slice(0, limit);\n\tconst entries: IWalletLedgerEntry[] = page.map((r) => ({\n\t\tid: r.id,\n\t\tsubscriberType: r.subscriberType,\n\t\tsubscriberId: r.subscriberId,\n\t\tcurrency: r.currency,\n\t\ttype: r.type,\n\t\tamount: BigInt(r.amount),\n\t\tbalanceAfter: BigInt(r.balanceAfter),\n\t\tdescription: r.description,\n\t\tidempotencyKey: r.idempotencyKey,\n\t\tmetadata: r.metadata ?? {},\n\t\tproviderTxId: r.providerTxId,\n\t\tcreatedAt: new Date(r.createdAt).toISOString(),\n\t}));\n\n\tconst lastRow = page[page.length - 1];\n\tconst nextCursor =\n\t\trows.length > limit && lastRow ? encodeLedgerCursor(lastRow.createdAtRaw, lastRow.id) : null;\n\treturn { entries, nextCursor };\n}\n\n// A plan's wallet economics with every default applied. Null when the wallet\n// subsystem is off (no config.wallet) or the plan defines no wallet.\nexport interface IResolvedPlanWallet {\n\tcurrency: string;\n\tprecision: number;\n\toverdraftLimit: bigint;\n\tgrantAmount: bigint | null;\n\tgrantPeriod: 'month' | 'week' | 'day';\n\trates: Record<string, IWalletRate>;\n}\n\nexport function resolvePlanWallet(\n\tplan: IBillingPlan,\n\tconfig: IBillingConfig,\n): IResolvedPlanWallet | null {\n\tif (!config.wallet || !plan.wallet) return null;\n\treturn {\n\t\tcurrency: normalizeCurrency(plan.wallet.currency ?? config.wallet.currency ?? 'USD'),\n\t\tprecision: plan.wallet.precision ?? config.wallet.precision ?? 2,\n\t\toverdraftLimit: plan.wallet.overdraftLimit ?? 0n,\n\t\tgrantAmount: plan.wallet.grantAmount ?? null,\n\t\tgrantPeriod: plan.wallet.grantPeriod ?? 'month',\n\t\trates: plan.wallet.rates ?? {},\n\t};\n}\n\n// UTC period key for periodic grants: '2026-09' (month), '2026-09-04' (day),\n// '2026-W36' (ISO week — note the ISO week-numbering year at boundaries).\nexport function currentGrantPeriod(period: 'month' | 'week' | 'day', now = new Date()): string {\n\tconst y = now.getUTCFullYear();\n\tconst m = String(now.getUTCMonth() + 1).padStart(2, '0');\n\tconst d = String(now.getUTCDate()).padStart(2, '0');\n\tif (period === 'month') return `${y}-${m}`;\n\tif (period === 'day') return `${y}-${m}-${d}`;\n\t// ISO week: shift to the Thursday of the current week, whose year is the\n\t// ISO week-numbering year; week 1 contains January 4th.\n\tconst thursday = new Date(Date.UTC(y, now.getUTCMonth(), now.getUTCDate()));\n\tthursday.setUTCDate(thursday.getUTCDate() + 4 - (thursday.getUTCDay() || 7));\n\tconst isoYear = thursday.getUTCFullYear();\n\tconst jan4 = new Date(Date.UTC(isoYear, 0, 4));\n\tjan4.setUTCDate(jan4.getUTCDate() + 4 - (jan4.getUTCDay() || 7));\n\tconst week = 1 + Math.round((thursday.getTime() - jan4.getTime()) / (7 * 86_400_000));\n\treturn `${isoYear}-W${String(week).padStart(2, '0')}`;\n}\n\nexport interface IGrantResult {\n\tgranted: boolean; // false when this period's grant was already applied\n\tbalance: bigint | null; // new balance when granted, null otherwise\n}\n\n// Apply a periodic grant exactly once per (subscriber, currency, period).\n// The grant marker and the credit commit in ONE transaction, so a crash\n// between them cannot mark a period as granted without crediting it.\nexport async function ensurePeriodicGrant(\n\topts: IWalletSubscriber & {\n\t\tamount: bigint; // positive\n\t\tperiod: string; // from currentGrantPeriod()\n\t\tdescription?: string;\n\t},\n\tstore: IStoreAdapter,\n): Promise<IGrantResult> {\n\tif (opts.amount <= 0n) return { granted: false, balance: null };\n\n\t// Fast path: one indexed read per request once the period is granted.\n\tconst [seen] = await store.query<{ period: string }>(\n\t\t`SELECT period FROM fonderie_wallet_grants\n\t\tWHERE subscriber_type = $1 AND subscriber_id = $2 AND currency = $3 AND period = $4`,\n\t\t[opts.subscriberType, opts.subscriberId, opts.currency, opts.period],\n\t);\n\tif (seen) return { granted: false, balance: null };\n\n\ttry {\n\t\treturn await store.transaction(async (tx) => {\n\t\t\tconst [marked] = await tx.query<{ period: string }>(\n\t\t\t\t`INSERT INTO fonderie_wallet_grants (subscriber_type, subscriber_id, currency, period, amount)\n\t\t\t\tVALUES ($1, $2, $3, $4, $5)\n\t\t\t\tON CONFLICT (subscriber_type, subscriber_id, currency, period) DO NOTHING\n\t\t\t\tRETURNING period`,\n\t\t\t\t[\n\t\t\t\t\topts.subscriberType,\n\t\t\t\t\topts.subscriberId,\n\t\t\t\t\topts.currency,\n\t\t\t\t\topts.period,\n\t\t\t\t\topts.amount.toString(),\n\t\t\t\t],\n\t\t\t);\n\t\t\t// Lost the race — another request granted this period first.\n\t\t\tif (!marked) return { granted: false, balance: null };\n\n\t\t\tconst balance = await applyBalanceCredit(tx, opts, opts.amount);\n\n\t\t\tawait insertLedgerRow(tx, opts, {\n\t\t\t\ttype: 'grant',\n\t\t\t\tamount: opts.amount,\n\t\t\t\tbalanceAfter: balance,\n\t\t\t\tidempotencyKey: `grant:${opts.subscriberType}:${opts.subscriberId}:${opts.currency}:${opts.period}`,\n\t\t\t\tdescription: opts.description ?? `Periodic grant ${opts.period}`,\n\t\t\t\tmetadata: { period: opts.period },\n\t\t\t\tproviderTxId: null,\n\t\t\t});\n\n\t\t\treturn { granted: true, balance };\n\t\t});\n\t} catch (err) {\n\t\tif (isIdempotencyConflict(err)) return { granted: false, balance: null };\n\t\tthrow err;\n\t}\n}\n"],"mappings":"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","names":["setApiResponse","HTTP","setApiResponse","HTTP"]}
@@ -0,0 +1,85 @@
1
+ -- Stored-value wallet: balance cache, append-only ledger, periodic-grant
2
+ -- idempotency, and config-synced credit packs. All money amounts are BIGINT
3
+ -- in the smallest currency unit; the JS layer reads them as bigint.
4
+
5
+ -- Balance cache — fast read; NEVER written without a ledger row in the same
6
+ -- transaction. The ledger is the source of truth.
7
+ CREATE TABLE IF NOT EXISTS fonderie_wallet_balances (
8
+ subscriber_type TEXT NOT NULL,
9
+ subscriber_id UUID NOT NULL,
10
+ currency TEXT NOT NULL DEFAULT 'USD',
11
+ amount BIGINT NOT NULL DEFAULT 0,
12
+ version BIGINT NOT NULL DEFAULT 1,
13
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
14
+ CONSTRAINT fonderie_wallet_balances_subscriber_type_check
15
+ CHECK (subscriber_type IN ('user', 'workspace')),
16
+ PRIMARY KEY (subscriber_type, subscriber_id, currency)
17
+ );
18
+
19
+ -- Append-only ledger — every balance mutation is one row here. amount is
20
+ -- signed: positive = credit, negative = debit. balance_after snapshots the
21
+ -- cache after this row applied. idempotency_key makes every mutation
22
+ -- replay-safe (retries hit the UNIQUE constraint and roll back).
23
+ CREATE TABLE IF NOT EXISTS fonderie_wallet_ledger (
24
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
25
+ subscriber_type TEXT NOT NULL,
26
+ subscriber_id UUID NOT NULL,
27
+ currency TEXT NOT NULL DEFAULT 'USD',
28
+ type TEXT NOT NULL,
29
+ amount BIGINT NOT NULL,
30
+ balance_after BIGINT NOT NULL,
31
+ description TEXT,
32
+ idempotency_key TEXT NOT NULL UNIQUE,
33
+ metadata JSONB NOT NULL DEFAULT '{}',
34
+ provider_tx_id TEXT,
35
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
36
+ CONSTRAINT fonderie_wallet_ledger_subscriber_type_check
37
+ CHECK (subscriber_type IN ('user', 'workspace')),
38
+ CONSTRAINT fonderie_wallet_ledger_type_check
39
+ CHECK (type IN ('purchase', 'grant', 'usage', 'refund', 'adjustment')),
40
+ CONSTRAINT fonderie_wallet_ledger_amount_nonzero_check
41
+ CHECK (amount <> 0)
42
+ );
43
+
44
+ CREATE INDEX IF NOT EXISTS fonderie_wallet_ledger_subscriber_idx
45
+ ON fonderie_wallet_ledger (subscriber_type, subscriber_id, currency, created_at DESC, id DESC);
46
+
47
+ -- Periodic-grant idempotency — one row per subscriber/currency/period marks
48
+ -- the period's grant as applied (written in the same transaction as its
49
+ -- ledger row, so the mark and the credit commit or roll back together).
50
+ CREATE TABLE IF NOT EXISTS fonderie_wallet_grants (
51
+ subscriber_type TEXT NOT NULL,
52
+ subscriber_id UUID NOT NULL,
53
+ currency TEXT NOT NULL,
54
+ period TEXT NOT NULL,
55
+ amount BIGINT NOT NULL,
56
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
57
+ CONSTRAINT fonderie_wallet_grants_subscriber_type_check
58
+ CHECK (subscriber_type IN ('user', 'workspace')),
59
+ PRIMARY KEY (subscriber_type, subscriber_id, currency, period)
60
+ );
61
+
62
+ -- Credit packs — synced from IBillingConfig at boot (same pattern as
63
+ -- fonderie_plans). price_amount is in the provider's smallest unit.
64
+ CREATE TABLE IF NOT EXISTS fonderie_credit_packs (
65
+ id TEXT PRIMARY KEY,
66
+ name TEXT NOT NULL,
67
+ currency TEXT NOT NULL DEFAULT 'USD',
68
+ credits BIGINT NOT NULL,
69
+ price_amount BIGINT NOT NULL,
70
+ price_id TEXT,
71
+ active BOOLEAN NOT NULL DEFAULT true,
72
+ metadata JSONB NOT NULL DEFAULT '{}',
73
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
74
+ );
75
+
76
+ -- Per-plan wallet economics (currency/precision/grants/rates) synced from
77
+ -- config for ops visibility; enforcement reads config at runtime like policy.
78
+ ALTER TABLE fonderie_plans ADD COLUMN IF NOT EXISTS wallet JSONB;
79
+
80
+ -- Plan display prices widen with the config move to bigint — an INT column
81
+ -- would fail boot for currencies whose minor-unit prices exceed 2^31. Reads
82
+ -- convert back to JS numbers behind a 2^53 guard; the wire stays numeric.
83
+ ALTER TABLE fonderie_plans
84
+ ALTER COLUMN monthly_amount TYPE BIGINT,
85
+ ALTER COLUMN yearly_amount TYPE BIGINT;
package/dist/types.cjs CHANGED
@@ -20,12 +20,26 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/types.ts
21
21
  var types_exports = {};
22
22
  __export(types_exports, {
23
- BILLING_INTERVAL: () => BILLING_INTERVAL
23
+ BILLING_INTERVAL: () => BILLING_INTERVAL,
24
+ BILLING_INTERVALS: () => BILLING_INTERVALS,
25
+ WALLET_LEDGER_TYPES: () => WALLET_LEDGER_TYPES,
26
+ isBillingInterval: () => isBillingInterval
24
27
  });
25
28
  module.exports = __toCommonJS(types_exports);
26
- var BILLING_INTERVAL = { MONTH: "month", YEAR: "year" };
29
+ var BILLING_INTERVALS = ["month", "year"];
30
+ var BILLING_INTERVAL = {
31
+ MONTH: "month",
32
+ YEAR: "year"
33
+ };
34
+ function isBillingInterval(value) {
35
+ return BILLING_INTERVALS.includes(value);
36
+ }
37
+ var WALLET_LEDGER_TYPES = ["purchase", "grant", "usage", "refund", "adjustment"];
27
38
  // Annotate the CommonJS export names for ESM import in node:
28
39
  0 && (module.exports = {
29
- BILLING_INTERVAL
40
+ BILLING_INTERVAL,
41
+ BILLING_INTERVALS,
42
+ WALLET_LEDGER_TYPES,
43
+ isBillingInterval
30
44
  });
31
45
  //# sourceMappingURL=types.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/types.ts"],"sourcesContent":["export type SubscriberType = 'user' | 'workspace';\n\n// Billing interval — one source for the 'month' | 'year' literals.\nexport const BILLING_INTERVAL = { MONTH: 'month', YEAR: 'year' } as const;\nexport type BillingInterval = (typeof BILLING_INTERVAL)[keyof typeof BILLING_INTERVAL];\n\n// ── Policy ────────────────────────────────────────────────────────\n\nexport type PolicyEntry =\n\t| { enabled: boolean }\n\t| {\n\t\t\tlimit: number | null; // advertised ceiling; null = unlimited\n\t\t\tbuffer?: number; // unadvertised grace on top of limit\n\t\t\twarnAt?: number; // fraction of limit to trigger warning (0–1)\n\t\t\twindow?: string; // '1d' | '30d' | '1h' — if set, auto rate-limited\n\t\t\tunit?: string; // display only, e.g. 'mb', 'requests'\n\t };\n\nexport type LimitStatus = 'ok' | 'warning' | 'over_limit' | 'blocked';\n\nexport type IPolicyStatus =\n\t| { type: 'feature'; enabled: boolean }\n\t| {\n\t\t\ttype: 'counter';\n\t\t\tlimit: number | null; // advertised — safe to send to client\n\t\t\tused: number;\n\t\t\tstatus: LimitStatus;\n\t\t\tresetsAt: string | null; // ISO string for windowed counters, null otherwise\n\t };\n\nexport interface IBillingContext {\n\tsubscriber: { type: SubscriberType; id: string };\n\tplan: string;\n\tactive: boolean; // subscription is active or trialing\n\tstatuses: Record<string, IPolicyStatus>;\n}\n\n// ── Subscription ──────────────────────────────────────────────────\n\nexport interface ISubscription {\n\tid: string;\n\tsubscriberType: SubscriberType;\n\tsubscriberId: string;\n\tplan: string;\n\tinterval: 'month' | 'year';\n\tstatus: SubscriptionStatus;\n\tproviderCustomerId: string | null;\n\tproviderSubscriptionId: string | null;\n\tcurrentPeriodStart: string | null;\n\tcurrentPeriodEnd: string | null;\n\tcancelAtPeriodEnd: boolean;\n\ttrialEndsAt: string | null;\n\tcreatedAt: string;\n}\n\nexport type SubscriptionStatus =\n\t| 'trialing'\n\t| 'active'\n\t| 'past_due'\n\t| 'canceled'\n\t| 'incomplete'\n\t| 'paused';\n\n// ── DB plan (read from fonderie_plans table) ──────────────────────\n\nexport interface IPlan {\n\tid: string;\n\tname: string;\n\tseats: number | null;\n\ttrialDays: number;\n\tmonthlyAmount: number | null;\n\tmonthlyPriceId: string | null;\n\tyearlyAmount: number | null;\n\tyearlyPriceId: string | null;\n\tdescription: string | null;\n\ttier: number;\n\tfeatures: IPlanFeature[];\n\tmetadata: Record<string, unknown>;\n}\n\nexport interface IPlanFeature {\n\tname: string;\n\tdescription: string;\n\tenabled: boolean;\n\tlimit?: number;\n}\n\n// ── Usage ─────────────────────────────────────────────────────────\n\nexport interface IUsageRecord {\n\tid: string;\n\tsubscriberType: SubscriberType;\n\tsubscriberId: string;\n\tmetric: string;\n\tquantity: number;\n\trecordedAt: string;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAGO,IAAM,mBAAmB,EAAE,OAAO,SAAS,MAAM,OAAO;","names":[]}
1
+ {"version":3,"sources":["../src/types.ts"],"sourcesContent":["export type SubscriberType = 'user' | 'workspace';\n\n// Billing interval — ONE source for the 'month' | 'year' literals. The tuple\n// drives the type, the zod schema, and runtime membership checks; the object\n// is the ergonomic dot-access companion (`satisfies` pins its values to the\n// union). Adding an interval starts here and the compiler walks you through\n// every exhaustive switch that must learn about it.\nexport const BILLING_INTERVALS = ['month', 'year'] as const;\nexport type BillingInterval = (typeof BILLING_INTERVALS)[number];\nexport const BILLING_INTERVAL = {\n\tMONTH: 'month',\n\tYEAR: 'year',\n} as const satisfies Record<string, BillingInterval>;\n\nexport function isBillingInterval(value: unknown): value is BillingInterval {\n\treturn (BILLING_INTERVALS as readonly unknown[]).includes(value);\n}\n\n// ── Policy ────────────────────────────────────────────────────────\n\nexport type PolicyEntry =\n\t| { enabled: boolean }\n\t| {\n\t\t\tlimit: number | null; // advertised ceiling; null = unlimited\n\t\t\tbuffer?: number; // unadvertised grace on top of limit\n\t\t\twarnAt?: number; // fraction of limit to trigger warning (0–1)\n\t\t\twindow?: string; // '1d' | '30d' | '1h' — if set, auto rate-limited\n\t\t\tunit?: string; // display only, e.g. 'mb', 'requests'\n\t };\n\nexport type LimitStatus = 'ok' | 'warning' | 'over_limit' | 'blocked';\n\nexport type IPolicyStatus =\n\t| { type: 'feature'; enabled: boolean }\n\t| {\n\t\t\ttype: 'counter';\n\t\t\tlimit: number | null; // advertised — safe to send to client\n\t\t\tused: number;\n\t\t\tstatus: LimitStatus;\n\t\t\tresetsAt: string | null; // ISO string for windowed counters, null otherwise\n\t };\n\n// Per-metric wallet pricing (plan-defined unit economics).\nexport interface IWalletRate {\n\tcost: bigint; // per unit, in the smallest wallet-currency unit\n\tunit?: string; // display only, e.g. 'msg', 'min'\n}\n\n// Wallet snapshot cached on ctx.meta['billing'] by withBilling when the\n// subscriber's plan defines wallet economics. Server-side only — bigint\n// values here never hit JSON.stringify; the HTTP surface uses IWalletDTO.\nexport interface IWalletContext {\n\tbalance: bigint;\n\tcurrency: string;\n\tprecision: number;\n\toverdraftLimit: bigint;\n\trates: Record<string, IWalletRate>;\n}\n\nexport interface IBillingContext {\n\tsubscriber: { type: SubscriberType; id: string };\n\tplan: string;\n\tactive: boolean; // subscription is active or trialing\n\tstatuses: Record<string, IPolicyStatus>;\n\twallet?: IWalletContext;\n}\n\n// ── Subscription ──────────────────────────────────────────────────\n\nexport interface ISubscription {\n\tid: string;\n\tsubscriberType: SubscriberType;\n\tsubscriberId: string;\n\tplan: string;\n\tinterval: BillingInterval;\n\tstatus: SubscriptionStatus;\n\tproviderCustomerId: string | null;\n\tproviderSubscriptionId: string | null;\n\tcurrentPeriodStart: string | null;\n\tcurrentPeriodEnd: string | null;\n\tcancelAtPeriodEnd: boolean;\n\ttrialEndsAt: string | null;\n\tcreatedAt: string;\n}\n\nexport type SubscriptionStatus =\n\t| 'trialing'\n\t| 'active'\n\t| 'past_due'\n\t| 'canceled'\n\t| 'incomplete'\n\t| 'paused';\n\n// ── DB plan (read from fonderie_plans table) ──────────────────────\n\nexport interface IPlan {\n\tid: string;\n\tname: string;\n\tseats: number | null;\n\ttrialDays: number;\n\tmonthlyAmount: number | null;\n\tmonthlyPriceId: string | null;\n\tyearlyAmount: number | null;\n\tyearlyPriceId: string | null;\n\tdescription: string | null;\n\ttier: number;\n\tfeatures: IPlanFeature[];\n\tmetadata: Record<string, unknown>;\n}\n\nexport interface IPlanFeature {\n\tname: string;\n\tdescription: string;\n\tenabled: boolean;\n\tlimit?: number;\n}\n\n// ── Wallet ────────────────────────────────────────────────────────\n\nexport const WALLET_LEDGER_TYPES = ['purchase', 'grant', 'usage', 'refund', 'adjustment'] as const;\nexport type WalletLedgerType = (typeof WALLET_LEDGER_TYPES)[number];\n\nexport interface IWalletBalance {\n\tbalance: bigint;\n\tversion: number;\n\tupdatedAt: string | null; // ISO string; null when no balance row exists yet\n}\n\nexport interface IWalletLedgerEntry {\n\tid: string;\n\tsubscriberType: SubscriberType;\n\tsubscriberId: string;\n\tcurrency: string;\n\ttype: WalletLedgerType;\n\tamount: bigint; // signed: positive = credit, negative = debit\n\tbalanceAfter: bigint;\n\tdescription: string | null;\n\tidempotencyKey: string;\n\tmetadata: Record<string, unknown>;\n\tproviderTxId: string | null;\n\tcreatedAt: string;\n}\n\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAOO,IAAM,oBAAoB,CAAC,SAAS,MAAM;AAE1C,IAAM,mBAAmB;AAAA,EAC/B,OAAO;AAAA,EACP,MAAM;AACP;AAEO,SAAS,kBAAkB,OAA0C;AAC3E,SAAQ,kBAAyC,SAAS,KAAK;AAChE;AAuGO,IAAM,sBAAsB,CAAC,YAAY,SAAS,SAAS,UAAU,YAAY;","names":[]}
package/dist/types.d.cts CHANGED
@@ -1,9 +1,11 @@
1
1
  type SubscriberType = 'user' | 'workspace';
2
+ declare const BILLING_INTERVALS: readonly ["month", "year"];
3
+ type BillingInterval = (typeof BILLING_INTERVALS)[number];
2
4
  declare const BILLING_INTERVAL: {
3
5
  readonly MONTH: "month";
4
6
  readonly YEAR: "year";
5
7
  };
6
- type BillingInterval = (typeof BILLING_INTERVAL)[keyof typeof BILLING_INTERVAL];
8
+ declare function isBillingInterval(value: unknown): value is BillingInterval;
7
9
  type PolicyEntry = {
8
10
  enabled: boolean;
9
11
  } | {
@@ -24,6 +26,17 @@ type IPolicyStatus = {
24
26
  status: LimitStatus;
25
27
  resetsAt: string | null;
26
28
  };
29
+ interface IWalletRate {
30
+ cost: bigint;
31
+ unit?: string;
32
+ }
33
+ interface IWalletContext {
34
+ balance: bigint;
35
+ currency: string;
36
+ precision: number;
37
+ overdraftLimit: bigint;
38
+ rates: Record<string, IWalletRate>;
39
+ }
27
40
  interface IBillingContext {
28
41
  subscriber: {
29
42
  type: SubscriberType;
@@ -32,13 +45,14 @@ interface IBillingContext {
32
45
  plan: string;
33
46
  active: boolean;
34
47
  statuses: Record<string, IPolicyStatus>;
48
+ wallet?: IWalletContext;
35
49
  }
36
50
  interface ISubscription {
37
51
  id: string;
38
52
  subscriberType: SubscriberType;
39
53
  subscriberId: string;
40
54
  plan: string;
41
- interval: 'month' | 'year';
55
+ interval: BillingInterval;
42
56
  status: SubscriptionStatus;
43
57
  providerCustomerId: string | null;
44
58
  providerSubscriptionId: string | null;
@@ -69,13 +83,26 @@ interface IPlanFeature {
69
83
  enabled: boolean;
70
84
  limit?: number;
71
85
  }
72
- interface IUsageRecord {
86
+ declare const WALLET_LEDGER_TYPES: readonly ["purchase", "grant", "usage", "refund", "adjustment"];
87
+ type WalletLedgerType = (typeof WALLET_LEDGER_TYPES)[number];
88
+ interface IWalletBalance {
89
+ balance: bigint;
90
+ version: number;
91
+ updatedAt: string | null;
92
+ }
93
+ interface IWalletLedgerEntry {
73
94
  id: string;
74
95
  subscriberType: SubscriberType;
75
96
  subscriberId: string;
76
- metric: string;
77
- quantity: number;
78
- recordedAt: string;
97
+ currency: string;
98
+ type: WalletLedgerType;
99
+ amount: bigint;
100
+ balanceAfter: bigint;
101
+ description: string | null;
102
+ idempotencyKey: string;
103
+ metadata: Record<string, unknown>;
104
+ providerTxId: string | null;
105
+ createdAt: string;
79
106
  }
80
107
 
81
- export { BILLING_INTERVAL, type BillingInterval, type IBillingContext, type IPlan, type IPlanFeature, type IPolicyStatus, type ISubscription, type IUsageRecord, type LimitStatus, type PolicyEntry, type SubscriberType, type SubscriptionStatus };
108
+ export { BILLING_INTERVAL, BILLING_INTERVALS, type BillingInterval, type IBillingContext, type IPlan, type IPlanFeature, type IPolicyStatus, type ISubscription, type IWalletBalance, type IWalletContext, type IWalletLedgerEntry, type IWalletRate, type LimitStatus, type PolicyEntry, type SubscriberType, type SubscriptionStatus, WALLET_LEDGER_TYPES, type WalletLedgerType, isBillingInterval };
package/dist/types.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  type SubscriberType = 'user' | 'workspace';
2
+ declare const BILLING_INTERVALS: readonly ["month", "year"];
3
+ type BillingInterval = (typeof BILLING_INTERVALS)[number];
2
4
  declare const BILLING_INTERVAL: {
3
5
  readonly MONTH: "month";
4
6
  readonly YEAR: "year";
5
7
  };
6
- type BillingInterval = (typeof BILLING_INTERVAL)[keyof typeof BILLING_INTERVAL];
8
+ declare function isBillingInterval(value: unknown): value is BillingInterval;
7
9
  type PolicyEntry = {
8
10
  enabled: boolean;
9
11
  } | {
@@ -24,6 +26,17 @@ type IPolicyStatus = {
24
26
  status: LimitStatus;
25
27
  resetsAt: string | null;
26
28
  };
29
+ interface IWalletRate {
30
+ cost: bigint;
31
+ unit?: string;
32
+ }
33
+ interface IWalletContext {
34
+ balance: bigint;
35
+ currency: string;
36
+ precision: number;
37
+ overdraftLimit: bigint;
38
+ rates: Record<string, IWalletRate>;
39
+ }
27
40
  interface IBillingContext {
28
41
  subscriber: {
29
42
  type: SubscriberType;
@@ -32,13 +45,14 @@ interface IBillingContext {
32
45
  plan: string;
33
46
  active: boolean;
34
47
  statuses: Record<string, IPolicyStatus>;
48
+ wallet?: IWalletContext;
35
49
  }
36
50
  interface ISubscription {
37
51
  id: string;
38
52
  subscriberType: SubscriberType;
39
53
  subscriberId: string;
40
54
  plan: string;
41
- interval: 'month' | 'year';
55
+ interval: BillingInterval;
42
56
  status: SubscriptionStatus;
43
57
  providerCustomerId: string | null;
44
58
  providerSubscriptionId: string | null;
@@ -69,13 +83,26 @@ interface IPlanFeature {
69
83
  enabled: boolean;
70
84
  limit?: number;
71
85
  }
72
- interface IUsageRecord {
86
+ declare const WALLET_LEDGER_TYPES: readonly ["purchase", "grant", "usage", "refund", "adjustment"];
87
+ type WalletLedgerType = (typeof WALLET_LEDGER_TYPES)[number];
88
+ interface IWalletBalance {
89
+ balance: bigint;
90
+ version: number;
91
+ updatedAt: string | null;
92
+ }
93
+ interface IWalletLedgerEntry {
73
94
  id: string;
74
95
  subscriberType: SubscriberType;
75
96
  subscriberId: string;
76
- metric: string;
77
- quantity: number;
78
- recordedAt: string;
97
+ currency: string;
98
+ type: WalletLedgerType;
99
+ amount: bigint;
100
+ balanceAfter: bigint;
101
+ description: string | null;
102
+ idempotencyKey: string;
103
+ metadata: Record<string, unknown>;
104
+ providerTxId: string | null;
105
+ createdAt: string;
79
106
  }
80
107
 
81
- export { BILLING_INTERVAL, type BillingInterval, type IBillingContext, type IPlan, type IPlanFeature, type IPolicyStatus, type ISubscription, type IUsageRecord, type LimitStatus, type PolicyEntry, type SubscriberType, type SubscriptionStatus };
108
+ export { BILLING_INTERVAL, BILLING_INTERVALS, type BillingInterval, type IBillingContext, type IPlan, type IPlanFeature, type IPolicyStatus, type ISubscription, type IWalletBalance, type IWalletContext, type IWalletLedgerEntry, type IWalletRate, type LimitStatus, type PolicyEntry, type SubscriberType, type SubscriptionStatus, WALLET_LEDGER_TYPES, type WalletLedgerType, isBillingInterval };
package/dist/types.js CHANGED
@@ -1,6 +1,17 @@
1
1
  // src/types.ts
2
- var BILLING_INTERVAL = { MONTH: "month", YEAR: "year" };
2
+ var BILLING_INTERVALS = ["month", "year"];
3
+ var BILLING_INTERVAL = {
4
+ MONTH: "month",
5
+ YEAR: "year"
6
+ };
7
+ function isBillingInterval(value) {
8
+ return BILLING_INTERVALS.includes(value);
9
+ }
10
+ var WALLET_LEDGER_TYPES = ["purchase", "grant", "usage", "refund", "adjustment"];
3
11
  export {
4
- BILLING_INTERVAL
12
+ BILLING_INTERVAL,
13
+ BILLING_INTERVALS,
14
+ WALLET_LEDGER_TYPES,
15
+ isBillingInterval
5
16
  };
6
17
  //# sourceMappingURL=types.js.map
package/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/types.ts"],"sourcesContent":["export type SubscriberType = 'user' | 'workspace';\n\n// Billing interval — one source for the 'month' | 'year' literals.\nexport const BILLING_INTERVAL = { MONTH: 'month', YEAR: 'year' } as const;\nexport type BillingInterval = (typeof BILLING_INTERVAL)[keyof typeof BILLING_INTERVAL];\n\n// ── Policy ────────────────────────────────────────────────────────\n\nexport type PolicyEntry =\n\t| { enabled: boolean }\n\t| {\n\t\t\tlimit: number | null; // advertised ceiling; null = unlimited\n\t\t\tbuffer?: number; // unadvertised grace on top of limit\n\t\t\twarnAt?: number; // fraction of limit to trigger warning (0–1)\n\t\t\twindow?: string; // '1d' | '30d' | '1h' — if set, auto rate-limited\n\t\t\tunit?: string; // display only, e.g. 'mb', 'requests'\n\t };\n\nexport type LimitStatus = 'ok' | 'warning' | 'over_limit' | 'blocked';\n\nexport type IPolicyStatus =\n\t| { type: 'feature'; enabled: boolean }\n\t| {\n\t\t\ttype: 'counter';\n\t\t\tlimit: number | null; // advertised — safe to send to client\n\t\t\tused: number;\n\t\t\tstatus: LimitStatus;\n\t\t\tresetsAt: string | null; // ISO string for windowed counters, null otherwise\n\t };\n\nexport interface IBillingContext {\n\tsubscriber: { type: SubscriberType; id: string };\n\tplan: string;\n\tactive: boolean; // subscription is active or trialing\n\tstatuses: Record<string, IPolicyStatus>;\n}\n\n// ── Subscription ──────────────────────────────────────────────────\n\nexport interface ISubscription {\n\tid: string;\n\tsubscriberType: SubscriberType;\n\tsubscriberId: string;\n\tplan: string;\n\tinterval: 'month' | 'year';\n\tstatus: SubscriptionStatus;\n\tproviderCustomerId: string | null;\n\tproviderSubscriptionId: string | null;\n\tcurrentPeriodStart: string | null;\n\tcurrentPeriodEnd: string | null;\n\tcancelAtPeriodEnd: boolean;\n\ttrialEndsAt: string | null;\n\tcreatedAt: string;\n}\n\nexport type SubscriptionStatus =\n\t| 'trialing'\n\t| 'active'\n\t| 'past_due'\n\t| 'canceled'\n\t| 'incomplete'\n\t| 'paused';\n\n// ── DB plan (read from fonderie_plans table) ──────────────────────\n\nexport interface IPlan {\n\tid: string;\n\tname: string;\n\tseats: number | null;\n\ttrialDays: number;\n\tmonthlyAmount: number | null;\n\tmonthlyPriceId: string | null;\n\tyearlyAmount: number | null;\n\tyearlyPriceId: string | null;\n\tdescription: string | null;\n\ttier: number;\n\tfeatures: IPlanFeature[];\n\tmetadata: Record<string, unknown>;\n}\n\nexport interface IPlanFeature {\n\tname: string;\n\tdescription: string;\n\tenabled: boolean;\n\tlimit?: number;\n}\n\n// ── Usage ─────────────────────────────────────────────────────────\n\nexport interface IUsageRecord {\n\tid: string;\n\tsubscriberType: SubscriberType;\n\tsubscriberId: string;\n\tmetric: string;\n\tquantity: number;\n\trecordedAt: string;\n}\n"],"mappings":";AAGO,IAAM,mBAAmB,EAAE,OAAO,SAAS,MAAM,OAAO;","names":[]}
1
+ {"version":3,"sources":["../src/types.ts"],"sourcesContent":["export type SubscriberType = 'user' | 'workspace';\n\n// Billing interval — ONE source for the 'month' | 'year' literals. The tuple\n// drives the type, the zod schema, and runtime membership checks; the object\n// is the ergonomic dot-access companion (`satisfies` pins its values to the\n// union). Adding an interval starts here and the compiler walks you through\n// every exhaustive switch that must learn about it.\nexport const BILLING_INTERVALS = ['month', 'year'] as const;\nexport type BillingInterval = (typeof BILLING_INTERVALS)[number];\nexport const BILLING_INTERVAL = {\n\tMONTH: 'month',\n\tYEAR: 'year',\n} as const satisfies Record<string, BillingInterval>;\n\nexport function isBillingInterval(value: unknown): value is BillingInterval {\n\treturn (BILLING_INTERVALS as readonly unknown[]).includes(value);\n}\n\n// ── Policy ────────────────────────────────────────────────────────\n\nexport type PolicyEntry =\n\t| { enabled: boolean }\n\t| {\n\t\t\tlimit: number | null; // advertised ceiling; null = unlimited\n\t\t\tbuffer?: number; // unadvertised grace on top of limit\n\t\t\twarnAt?: number; // fraction of limit to trigger warning (0–1)\n\t\t\twindow?: string; // '1d' | '30d' | '1h' — if set, auto rate-limited\n\t\t\tunit?: string; // display only, e.g. 'mb', 'requests'\n\t };\n\nexport type LimitStatus = 'ok' | 'warning' | 'over_limit' | 'blocked';\n\nexport type IPolicyStatus =\n\t| { type: 'feature'; enabled: boolean }\n\t| {\n\t\t\ttype: 'counter';\n\t\t\tlimit: number | null; // advertised — safe to send to client\n\t\t\tused: number;\n\t\t\tstatus: LimitStatus;\n\t\t\tresetsAt: string | null; // ISO string for windowed counters, null otherwise\n\t };\n\n// Per-metric wallet pricing (plan-defined unit economics).\nexport interface IWalletRate {\n\tcost: bigint; // per unit, in the smallest wallet-currency unit\n\tunit?: string; // display only, e.g. 'msg', 'min'\n}\n\n// Wallet snapshot cached on ctx.meta['billing'] by withBilling when the\n// subscriber's plan defines wallet economics. Server-side only — bigint\n// values here never hit JSON.stringify; the HTTP surface uses IWalletDTO.\nexport interface IWalletContext {\n\tbalance: bigint;\n\tcurrency: string;\n\tprecision: number;\n\toverdraftLimit: bigint;\n\trates: Record<string, IWalletRate>;\n}\n\nexport interface IBillingContext {\n\tsubscriber: { type: SubscriberType; id: string };\n\tplan: string;\n\tactive: boolean; // subscription is active or trialing\n\tstatuses: Record<string, IPolicyStatus>;\n\twallet?: IWalletContext;\n}\n\n// ── Subscription ──────────────────────────────────────────────────\n\nexport interface ISubscription {\n\tid: string;\n\tsubscriberType: SubscriberType;\n\tsubscriberId: string;\n\tplan: string;\n\tinterval: BillingInterval;\n\tstatus: SubscriptionStatus;\n\tproviderCustomerId: string | null;\n\tproviderSubscriptionId: string | null;\n\tcurrentPeriodStart: string | null;\n\tcurrentPeriodEnd: string | null;\n\tcancelAtPeriodEnd: boolean;\n\ttrialEndsAt: string | null;\n\tcreatedAt: string;\n}\n\nexport type SubscriptionStatus =\n\t| 'trialing'\n\t| 'active'\n\t| 'past_due'\n\t| 'canceled'\n\t| 'incomplete'\n\t| 'paused';\n\n// ── DB plan (read from fonderie_plans table) ──────────────────────\n\nexport interface IPlan {\n\tid: string;\n\tname: string;\n\tseats: number | null;\n\ttrialDays: number;\n\tmonthlyAmount: number | null;\n\tmonthlyPriceId: string | null;\n\tyearlyAmount: number | null;\n\tyearlyPriceId: string | null;\n\tdescription: string | null;\n\ttier: number;\n\tfeatures: IPlanFeature[];\n\tmetadata: Record<string, unknown>;\n}\n\nexport interface IPlanFeature {\n\tname: string;\n\tdescription: string;\n\tenabled: boolean;\n\tlimit?: number;\n}\n\n// ── Wallet ────────────────────────────────────────────────────────\n\nexport const WALLET_LEDGER_TYPES = ['purchase', 'grant', 'usage', 'refund', 'adjustment'] as const;\nexport type WalletLedgerType = (typeof WALLET_LEDGER_TYPES)[number];\n\nexport interface IWalletBalance {\n\tbalance: bigint;\n\tversion: number;\n\tupdatedAt: string | null; // ISO string; null when no balance row exists yet\n}\n\nexport interface IWalletLedgerEntry {\n\tid: string;\n\tsubscriberType: SubscriberType;\n\tsubscriberId: string;\n\tcurrency: string;\n\ttype: WalletLedgerType;\n\tamount: bigint; // signed: positive = credit, negative = debit\n\tbalanceAfter: bigint;\n\tdescription: string | null;\n\tidempotencyKey: string;\n\tmetadata: Record<string, unknown>;\n\tproviderTxId: string | null;\n\tcreatedAt: string;\n}\n\n"],"mappings":";AAOO,IAAM,oBAAoB,CAAC,SAAS,MAAM;AAE1C,IAAM,mBAAmB;AAAA,EAC/B,OAAO;AAAA,EACP,MAAM;AACP;AAEO,SAAS,kBAAkB,OAA0C;AAC3E,SAAQ,kBAAyC,SAAS,KAAK;AAChE;AAuGO,IAAM,sBAAsB,CAAC,YAAY,SAAS,SAAS,UAAU,YAAY;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fonderie/billing",
3
- "version": "5.3.1",
3
+ "version": "6.0.0",
4
4
  "description": "SaaS billing in one module — config-driven plan catalogue, Stripe subscriptions, polymorphic user and workspace billing surfaces, usage metering, and webhook handling.",
5
5
  "keywords": [
6
6
  "fonderiejs",
@@ -52,7 +52,7 @@
52
52
  "check": "biome check --write src"
53
53
  },
54
54
  "optionalDependencies": {
55
- "stripe": "^22.5.0"
55
+ "stripe": "^22.6.0"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "@fonderie/core": "^0.5.0",
@@ -61,7 +61,7 @@
61
61
  "devDependencies": {
62
62
  "@fonderie/core": "../core",
63
63
  "@fonderie/store": "../store",
64
- "@types/node": "^26.2.0",
64
+ "@types/node": "^26.4.0",
65
65
  "tsup": "^8.5.1",
66
66
  "tsx": "^4.23.12",
67
67
  "typescript": "^6.0.3"
@@ -85,6 +85,6 @@
85
85
  "url": "https://github.com/fonderiejs/fonderie/issues"
86
86
  },
87
87
  "dependencies": {
88
- "zod": "^4.4.3"
88
+ "zod": "^4.5.1"
89
89
  }
90
90
  }