@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.
@@ -54,6 +54,9 @@ async function getSubscription(subscriberType, subscriberId, store) {
54
54
  }
55
55
 
56
56
  // src/utils.ts
57
+ function normalizeCurrency(currency) {
58
+ return currency.trim().toUpperCase();
59
+ }
57
60
  function parseWindowMs(window) {
58
61
  const n = parseInt(window, 10);
59
62
  const unit = window.slice(String(n).length);
@@ -138,6 +141,25 @@ var MESSAGE_KEYS = {
138
141
  limitBlocked: "billing.limit-blocked"
139
142
  };
140
143
 
144
+ // src/services/membership.ts
145
+ async function isWorkspaceMember(userId, workspaceId, store) {
146
+ try {
147
+ const rows = await store.query(
148
+ `SELECT 1 AS ok
149
+ FROM fonderie_role_user_workspaces
150
+ WHERE user_id = $1
151
+ AND workspace_id = $2
152
+ AND removed = false
153
+ AND suspended = false
154
+ LIMIT 1`,
155
+ [userId, workspaceId]
156
+ );
157
+ return rows.length > 0;
158
+ } catch {
159
+ return false;
160
+ }
161
+ }
162
+
141
163
  // src/services/policy.ts
142
164
  function buildBillingContext(opts) {
143
165
  const { subscriber, plan, active, counters } = opts;
@@ -166,12 +188,140 @@ function buildBillingContext(opts) {
166
188
  return { subscriber, plan: plan.name, active, statuses };
167
189
  }
168
190
 
191
+ // src/services/wallet.ts
192
+ var UNIQUE_VIOLATION = "23505";
193
+ function isIdempotencyConflict(err) {
194
+ const e = err;
195
+ if (e?.code !== UNIQUE_VIOLATION) return false;
196
+ if (typeof e.constraint === "string") return e.constraint.includes("idempotency_key");
197
+ return typeof e.message === "string" && e.message.includes("idempotency_key");
198
+ }
199
+ async function applyBalanceCredit(tx, sub, amount) {
200
+ const [row] = await tx.query(
201
+ `INSERT INTO fonderie_wallet_balances (subscriber_type, subscriber_id, currency, amount)
202
+ VALUES ($1, $2, $3, $4)
203
+ ON CONFLICT (subscriber_type, subscriber_id, currency) DO UPDATE SET
204
+ amount = fonderie_wallet_balances.amount + EXCLUDED.amount,
205
+ version = fonderie_wallet_balances.version + 1,
206
+ updated_at = now()
207
+ RETURNING amount`,
208
+ [sub.subscriberType, sub.subscriberId, sub.currency, amount.toString()]
209
+ );
210
+ return BigInt(row?.amount ?? "0");
211
+ }
212
+ async function insertLedgerRow(tx, sub, opts) {
213
+ await tx.query(
214
+ `INSERT INTO fonderie_wallet_ledger
215
+ (subscriber_type, subscriber_id, currency, type, amount, balance_after,
216
+ description, idempotency_key, metadata, provider_tx_id)
217
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)`,
218
+ [
219
+ sub.subscriberType,
220
+ sub.subscriberId,
221
+ sub.currency,
222
+ opts.type,
223
+ opts.amount.toString(),
224
+ opts.balanceAfter.toString(),
225
+ opts.description,
226
+ opts.idempotencyKey,
227
+ JSON.stringify(opts.metadata),
228
+ opts.providerTxId
229
+ ]
230
+ );
231
+ }
232
+ async function getWalletBalance(sub, store) {
233
+ const [row] = await store.query(
234
+ `SELECT amount, version, updated_at AS "updatedAt"
235
+ FROM fonderie_wallet_balances
236
+ WHERE subscriber_type = $1 AND subscriber_id = $2 AND currency = $3`,
237
+ [sub.subscriberType, sub.subscriberId, sub.currency]
238
+ );
239
+ if (!row) return { balance: 0n, version: 0, updatedAt: null };
240
+ return {
241
+ balance: BigInt(row.amount),
242
+ version: Number(row.version),
243
+ updatedAt: row.updatedAt ? new Date(row.updatedAt).toISOString() : null
244
+ };
245
+ }
246
+ function resolvePlanWallet(plan, config) {
247
+ if (!config.wallet || !plan.wallet) return null;
248
+ return {
249
+ currency: normalizeCurrency(plan.wallet.currency ?? config.wallet.currency ?? "USD"),
250
+ precision: plan.wallet.precision ?? config.wallet.precision ?? 2,
251
+ overdraftLimit: plan.wallet.overdraftLimit ?? 0n,
252
+ grantAmount: plan.wallet.grantAmount ?? null,
253
+ grantPeriod: plan.wallet.grantPeriod ?? "month",
254
+ rates: plan.wallet.rates ?? {}
255
+ };
256
+ }
257
+ function currentGrantPeriod(period, now = /* @__PURE__ */ new Date()) {
258
+ const y = now.getUTCFullYear();
259
+ const m = String(now.getUTCMonth() + 1).padStart(2, "0");
260
+ const d = String(now.getUTCDate()).padStart(2, "0");
261
+ if (period === "month") return `${y}-${m}`;
262
+ if (period === "day") return `${y}-${m}-${d}`;
263
+ const thursday = new Date(Date.UTC(y, now.getUTCMonth(), now.getUTCDate()));
264
+ thursday.setUTCDate(thursday.getUTCDate() + 4 - (thursday.getUTCDay() || 7));
265
+ const isoYear = thursday.getUTCFullYear();
266
+ const jan4 = new Date(Date.UTC(isoYear, 0, 4));
267
+ jan4.setUTCDate(jan4.getUTCDate() + 4 - (jan4.getUTCDay() || 7));
268
+ const week = 1 + Math.round((thursday.getTime() - jan4.getTime()) / (7 * 864e5));
269
+ return `${isoYear}-W${String(week).padStart(2, "0")}`;
270
+ }
271
+ async function ensurePeriodicGrant(opts, store) {
272
+ if (opts.amount <= 0n) return { granted: false, balance: null };
273
+ const [seen] = await store.query(
274
+ `SELECT period FROM fonderie_wallet_grants
275
+ WHERE subscriber_type = $1 AND subscriber_id = $2 AND currency = $3 AND period = $4`,
276
+ [opts.subscriberType, opts.subscriberId, opts.currency, opts.period]
277
+ );
278
+ if (seen) return { granted: false, balance: null };
279
+ try {
280
+ return await store.transaction(async (tx) => {
281
+ const [marked] = await tx.query(
282
+ `INSERT INTO fonderie_wallet_grants (subscriber_type, subscriber_id, currency, period, amount)
283
+ VALUES ($1, $2, $3, $4, $5)
284
+ ON CONFLICT (subscriber_type, subscriber_id, currency, period) DO NOTHING
285
+ RETURNING period`,
286
+ [
287
+ opts.subscriberType,
288
+ opts.subscriberId,
289
+ opts.currency,
290
+ opts.period,
291
+ opts.amount.toString()
292
+ ]
293
+ );
294
+ if (!marked) return { granted: false, balance: null };
295
+ const balance = await applyBalanceCredit(tx, opts, opts.amount);
296
+ await insertLedgerRow(tx, opts, {
297
+ type: "grant",
298
+ amount: opts.amount,
299
+ balanceAfter: balance,
300
+ idempotencyKey: `grant:${opts.subscriberType}:${opts.subscriberId}:${opts.currency}:${opts.period}`,
301
+ description: opts.description ?? `Periodic grant ${opts.period}`,
302
+ metadata: { period: opts.period },
303
+ providerTxId: null
304
+ });
305
+ return { granted: true, balance };
306
+ });
307
+ } catch (err) {
308
+ if (isIdempotencyConflict(err)) return { granted: false, balance: null };
309
+ throw err;
310
+ }
311
+ }
312
+
169
313
  // src/middlewares/billing.ts
170
314
  var notified = /* @__PURE__ */ new Set();
171
315
  function withBilling(store, config, backend) {
172
316
  return async (ctx, next) => {
173
317
  const subscriber = resolveSubscriber(ctx);
174
318
  if (!subscriber) return next();
319
+ if (subscriber.type === "workspace" && ctx.workspace?.id !== subscriber.id) {
320
+ if (!ctx.user) return next();
321
+ if (!await isWorkspaceMember(ctx.user.id, subscriber.id, store)) {
322
+ return (0, import_core2.setApiResponse)(import_core2.HTTP.FORBIDDEN, "FORBIDDEN", "Not a member of this workspace");
323
+ }
324
+ }
175
325
  const subscription = await getSubscription(subscriber.type, subscriber.id, store);
176
326
  const planName = subscription?.plan ?? config.plans[0]?.name ?? "free";
177
327
  const active = !subscription || subscription.status === "active" || subscription.status === "trialing";
@@ -186,6 +336,36 @@ function withBilling(store, config, backend) {
186
336
  }
187
337
  const billingCtx = buildBillingContext({ subscriber, plan, active, counters });
188
338
  ctx.meta["billing"] = billingCtx;
339
+ const planWallet = resolvePlanWallet(plan, config);
340
+ if (planWallet) {
341
+ try {
342
+ const sub = {
343
+ subscriberType: subscriber.type,
344
+ subscriberId: subscriber.id,
345
+ currency: planWallet.currency
346
+ };
347
+ if (active && planWallet.grantAmount !== null && planWallet.grantAmount > 0n) {
348
+ await ensurePeriodicGrant(
349
+ {
350
+ ...sub,
351
+ amount: planWallet.grantAmount,
352
+ period: currentGrantPeriod(planWallet.grantPeriod)
353
+ },
354
+ store
355
+ );
356
+ }
357
+ const { balance } = await getWalletBalance(sub, store);
358
+ billingCtx.wallet = {
359
+ balance,
360
+ currency: planWallet.currency,
361
+ precision: planWallet.precision,
362
+ overdraftLimit: planWallet.overdraftLimit,
363
+ rates: planWallet.rates
364
+ };
365
+ } catch (err) {
366
+ console.error("[billing] wallet context failed:", err.message);
367
+ }
368
+ }
189
369
  for (const [key, status] of Object.entries(billingCtx.statuses)) {
190
370
  if (status.type === "counter" && status.status === "blocked") {
191
371
  return (0, import_core2.setApiResponse)(
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/middlewares/index.ts","../../src/middlewares/require-plan.ts","../../src/services/subscriptions.ts","../../src/utils.ts","../../src/middlewares/billing.ts","../../src/config.ts","../../src/services/policy.ts"],"sourcesContent":["export { requirePlan } from './require-plan';\nexport { withBilling } from './billing';\n","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":["import_core"]}
1
+ {"version":3,"sources":["../../src/middlewares/index.ts","../../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":["export { requirePlan } from './require-plan';\nexport { withBilling } from './billing';\n","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":["import_core"]}
@@ -1,4 +1,4 @@
1
- export { r as requirePlan, w as withBilling } from '../index-DjAGcrSi.cjs';
1
+ export { r as requirePlan, w as withBilling } from '../index-Ca4pXx07.cjs';
2
2
  import '@fonderie/core';
3
3
  import '@fonderie/store';
4
4
  import '../types.cjs';
@@ -1,4 +1,4 @@
1
- export { r as requirePlan, w as withBilling } from '../index-Byy5mBE4.js';
1
+ export { r as requirePlan, w as withBilling } from '../index-BdNYDuhk.js';
2
2
  import '@fonderie/core';
3
3
  import '@fonderie/store';
4
4
  import '../types.js';
@@ -27,6 +27,9 @@ async function getSubscription(subscriberType, subscriberId, store) {
27
27
  }
28
28
 
29
29
  // src/utils.ts
30
+ function normalizeCurrency(currency) {
31
+ return currency.trim().toUpperCase();
32
+ }
30
33
  function parseWindowMs(window) {
31
34
  const n = parseInt(window, 10);
32
35
  const unit = window.slice(String(n).length);
@@ -111,6 +114,25 @@ var MESSAGE_KEYS = {
111
114
  limitBlocked: "billing.limit-blocked"
112
115
  };
113
116
 
117
+ // src/services/membership.ts
118
+ async function isWorkspaceMember(userId, workspaceId, store) {
119
+ try {
120
+ const rows = await store.query(
121
+ `SELECT 1 AS ok
122
+ FROM fonderie_role_user_workspaces
123
+ WHERE user_id = $1
124
+ AND workspace_id = $2
125
+ AND removed = false
126
+ AND suspended = false
127
+ LIMIT 1`,
128
+ [userId, workspaceId]
129
+ );
130
+ return rows.length > 0;
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
114
136
  // src/services/policy.ts
115
137
  function buildBillingContext(opts) {
116
138
  const { subscriber, plan, active, counters } = opts;
@@ -139,12 +161,140 @@ function buildBillingContext(opts) {
139
161
  return { subscriber, plan: plan.name, active, statuses };
140
162
  }
141
163
 
164
+ // src/services/wallet.ts
165
+ var UNIQUE_VIOLATION = "23505";
166
+ function isIdempotencyConflict(err) {
167
+ const e = err;
168
+ if (e?.code !== UNIQUE_VIOLATION) return false;
169
+ if (typeof e.constraint === "string") return e.constraint.includes("idempotency_key");
170
+ return typeof e.message === "string" && e.message.includes("idempotency_key");
171
+ }
172
+ async function applyBalanceCredit(tx, sub, amount) {
173
+ const [row] = await tx.query(
174
+ `INSERT INTO fonderie_wallet_balances (subscriber_type, subscriber_id, currency, amount)
175
+ VALUES ($1, $2, $3, $4)
176
+ ON CONFLICT (subscriber_type, subscriber_id, currency) DO UPDATE SET
177
+ amount = fonderie_wallet_balances.amount + EXCLUDED.amount,
178
+ version = fonderie_wallet_balances.version + 1,
179
+ updated_at = now()
180
+ RETURNING amount`,
181
+ [sub.subscriberType, sub.subscriberId, sub.currency, amount.toString()]
182
+ );
183
+ return BigInt(row?.amount ?? "0");
184
+ }
185
+ async function insertLedgerRow(tx, sub, opts) {
186
+ await tx.query(
187
+ `INSERT INTO fonderie_wallet_ledger
188
+ (subscriber_type, subscriber_id, currency, type, amount, balance_after,
189
+ description, idempotency_key, metadata, provider_tx_id)
190
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)`,
191
+ [
192
+ sub.subscriberType,
193
+ sub.subscriberId,
194
+ sub.currency,
195
+ opts.type,
196
+ opts.amount.toString(),
197
+ opts.balanceAfter.toString(),
198
+ opts.description,
199
+ opts.idempotencyKey,
200
+ JSON.stringify(opts.metadata),
201
+ opts.providerTxId
202
+ ]
203
+ );
204
+ }
205
+ async function getWalletBalance(sub, store) {
206
+ const [row] = await store.query(
207
+ `SELECT amount, version, updated_at AS "updatedAt"
208
+ FROM fonderie_wallet_balances
209
+ WHERE subscriber_type = $1 AND subscriber_id = $2 AND currency = $3`,
210
+ [sub.subscriberType, sub.subscriberId, sub.currency]
211
+ );
212
+ if (!row) return { balance: 0n, version: 0, updatedAt: null };
213
+ return {
214
+ balance: BigInt(row.amount),
215
+ version: Number(row.version),
216
+ updatedAt: row.updatedAt ? new Date(row.updatedAt).toISOString() : null
217
+ };
218
+ }
219
+ function resolvePlanWallet(plan, config) {
220
+ if (!config.wallet || !plan.wallet) return null;
221
+ return {
222
+ currency: normalizeCurrency(plan.wallet.currency ?? config.wallet.currency ?? "USD"),
223
+ precision: plan.wallet.precision ?? config.wallet.precision ?? 2,
224
+ overdraftLimit: plan.wallet.overdraftLimit ?? 0n,
225
+ grantAmount: plan.wallet.grantAmount ?? null,
226
+ grantPeriod: plan.wallet.grantPeriod ?? "month",
227
+ rates: plan.wallet.rates ?? {}
228
+ };
229
+ }
230
+ function currentGrantPeriod(period, now = /* @__PURE__ */ new Date()) {
231
+ const y = now.getUTCFullYear();
232
+ const m = String(now.getUTCMonth() + 1).padStart(2, "0");
233
+ const d = String(now.getUTCDate()).padStart(2, "0");
234
+ if (period === "month") return `${y}-${m}`;
235
+ if (period === "day") return `${y}-${m}-${d}`;
236
+ const thursday = new Date(Date.UTC(y, now.getUTCMonth(), now.getUTCDate()));
237
+ thursday.setUTCDate(thursday.getUTCDate() + 4 - (thursday.getUTCDay() || 7));
238
+ const isoYear = thursday.getUTCFullYear();
239
+ const jan4 = new Date(Date.UTC(isoYear, 0, 4));
240
+ jan4.setUTCDate(jan4.getUTCDate() + 4 - (jan4.getUTCDay() || 7));
241
+ const week = 1 + Math.round((thursday.getTime() - jan4.getTime()) / (7 * 864e5));
242
+ return `${isoYear}-W${String(week).padStart(2, "0")}`;
243
+ }
244
+ async function ensurePeriodicGrant(opts, store) {
245
+ if (opts.amount <= 0n) return { granted: false, balance: null };
246
+ const [seen] = await store.query(
247
+ `SELECT period FROM fonderie_wallet_grants
248
+ WHERE subscriber_type = $1 AND subscriber_id = $2 AND currency = $3 AND period = $4`,
249
+ [opts.subscriberType, opts.subscriberId, opts.currency, opts.period]
250
+ );
251
+ if (seen) return { granted: false, balance: null };
252
+ try {
253
+ return await store.transaction(async (tx) => {
254
+ const [marked] = await tx.query(
255
+ `INSERT INTO fonderie_wallet_grants (subscriber_type, subscriber_id, currency, period, amount)
256
+ VALUES ($1, $2, $3, $4, $5)
257
+ ON CONFLICT (subscriber_type, subscriber_id, currency, period) DO NOTHING
258
+ RETURNING period`,
259
+ [
260
+ opts.subscriberType,
261
+ opts.subscriberId,
262
+ opts.currency,
263
+ opts.period,
264
+ opts.amount.toString()
265
+ ]
266
+ );
267
+ if (!marked) return { granted: false, balance: null };
268
+ const balance = await applyBalanceCredit(tx, opts, opts.amount);
269
+ await insertLedgerRow(tx, opts, {
270
+ type: "grant",
271
+ amount: opts.amount,
272
+ balanceAfter: balance,
273
+ idempotencyKey: `grant:${opts.subscriberType}:${opts.subscriberId}:${opts.currency}:${opts.period}`,
274
+ description: opts.description ?? `Periodic grant ${opts.period}`,
275
+ metadata: { period: opts.period },
276
+ providerTxId: null
277
+ });
278
+ return { granted: true, balance };
279
+ });
280
+ } catch (err) {
281
+ if (isIdempotencyConflict(err)) return { granted: false, balance: null };
282
+ throw err;
283
+ }
284
+ }
285
+
142
286
  // src/middlewares/billing.ts
143
287
  var notified = /* @__PURE__ */ new Set();
144
288
  function withBilling(store, config, backend) {
145
289
  return async (ctx, next) => {
146
290
  const subscriber = resolveSubscriber(ctx);
147
291
  if (!subscriber) return next();
292
+ if (subscriber.type === "workspace" && ctx.workspace?.id !== subscriber.id) {
293
+ if (!ctx.user) return next();
294
+ if (!await isWorkspaceMember(ctx.user.id, subscriber.id, store)) {
295
+ return setApiResponse2(HTTP2.FORBIDDEN, "FORBIDDEN", "Not a member of this workspace");
296
+ }
297
+ }
148
298
  const subscription = await getSubscription(subscriber.type, subscriber.id, store);
149
299
  const planName = subscription?.plan ?? config.plans[0]?.name ?? "free";
150
300
  const active = !subscription || subscription.status === "active" || subscription.status === "trialing";
@@ -159,6 +309,36 @@ function withBilling(store, config, backend) {
159
309
  }
160
310
  const billingCtx = buildBillingContext({ subscriber, plan, active, counters });
161
311
  ctx.meta["billing"] = billingCtx;
312
+ const planWallet = resolvePlanWallet(plan, config);
313
+ if (planWallet) {
314
+ try {
315
+ const sub = {
316
+ subscriberType: subscriber.type,
317
+ subscriberId: subscriber.id,
318
+ currency: planWallet.currency
319
+ };
320
+ if (active && planWallet.grantAmount !== null && planWallet.grantAmount > 0n) {
321
+ await ensurePeriodicGrant(
322
+ {
323
+ ...sub,
324
+ amount: planWallet.grantAmount,
325
+ period: currentGrantPeriod(planWallet.grantPeriod)
326
+ },
327
+ store
328
+ );
329
+ }
330
+ const { balance } = await getWalletBalance(sub, store);
331
+ billingCtx.wallet = {
332
+ balance,
333
+ currency: planWallet.currency,
334
+ precision: planWallet.precision,
335
+ overdraftLimit: planWallet.overdraftLimit,
336
+ rates: planWallet.rates
337
+ };
338
+ } catch (err) {
339
+ console.error("[billing] wallet context failed:", err.message);
340
+ }
341
+ }
162
342
  for (const [key, status] of Object.entries(billingCtx.statuses)) {
163
343
  if (status.type === "counter" && status.status === "blocked") {
164
344
  return setApiResponse2(