@fonderie/billing 1.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.
@@ -0,0 +1,226 @@
1
+ // src/middlewares/require-plan.ts
2
+ import { setApiResponse, HTTP } from "@fonderie/core";
3
+
4
+ // src/services/subscriptions.ts
5
+ var SELECT_SUBSCRIPTION = `
6
+ SELECT
7
+ id,
8
+ subscriber_type AS "subscriberType",
9
+ subscriber_id AS "subscriberId",
10
+ plan,
11
+ interval,
12
+ status,
13
+ provider_customer_id AS "providerCustomerId",
14
+ provider_subscription_id AS "providerSubscriptionId",
15
+ current_period_start AS "currentPeriodStart",
16
+ current_period_end AS "currentPeriodEnd",
17
+ cancel_at_period_end AS "cancelAtPeriodEnd",
18
+ trial_ends_at AS "trialEndsAt",
19
+ created_at AS "createdAt"
20
+ FROM fonderie_subscriptions`;
21
+ async function getSubscription(subscriberType, subscriberId, store) {
22
+ const [row] = await store.query(
23
+ `${SELECT_SUBSCRIPTION} WHERE subscriber_type = $1 AND subscriber_id = $2`,
24
+ [subscriberType, subscriberId]
25
+ );
26
+ return row ?? null;
27
+ }
28
+
29
+ // src/utils.ts
30
+ function parseWindowMs(window) {
31
+ const n = parseInt(window, 10);
32
+ const unit = window.slice(String(n).length);
33
+ switch (unit) {
34
+ case "h":
35
+ return n * 36e5;
36
+ case "d":
37
+ return n * 864e5;
38
+ case "m":
39
+ return n * 6e4;
40
+ default:
41
+ throw new Error(`Unknown window unit: '${unit}' in '${window}'`);
42
+ }
43
+ }
44
+ function resolveSubscriber(ctx) {
45
+ const wsFromHeader = ctx.request.headers.get("x-workspace-id");
46
+ if (wsFromHeader) {
47
+ return {
48
+ type: "workspace",
49
+ id: wsFromHeader
50
+ };
51
+ }
52
+ if (ctx.workspace?.id) {
53
+ return {
54
+ type: "workspace",
55
+ id: ctx.workspace.id
56
+ };
57
+ }
58
+ if (ctx.user?.id) {
59
+ return {
60
+ type: "user",
61
+ id: ctx.user.id
62
+ };
63
+ }
64
+ return null;
65
+ }
66
+
67
+ // src/middlewares/require-plan.ts
68
+ function makeHandler(plans, store) {
69
+ const allowed = Array.isArray(plans) ? plans : [plans];
70
+ return async (ctx, next) => {
71
+ if (!ctx.user) {
72
+ return setApiResponse(HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
73
+ }
74
+ const subscriber = resolveSubscriber(ctx);
75
+ if (!subscriber) {
76
+ return setApiResponse(HTTP.BAD_REQUEST, "SUBSCRIBER_REQUIRED", "Subscriber context required");
77
+ }
78
+ const subscription = await getSubscription(subscriber.type, subscriber.id, store);
79
+ if (!subscription || !allowed.includes(subscription.plan)) {
80
+ return setApiResponse(
81
+ HTTP.PAYMENT_REQUIRED,
82
+ "PLAN_UPGRADE_REQUIRED",
83
+ "Plan upgrade required",
84
+ { required: allowed, current: subscription?.plan ?? "none" }
85
+ );
86
+ }
87
+ if (subscription.status !== "active" && subscription.status !== "trialing") {
88
+ return setApiResponse(
89
+ HTTP.PAYMENT_REQUIRED,
90
+ "SUBSCRIPTION_INACTIVE",
91
+ "Subscription is not active",
92
+ { status: subscription.status }
93
+ );
94
+ }
95
+ return next();
96
+ };
97
+ }
98
+ function requirePlan(plans, store, ctx, next) {
99
+ const handler = makeHandler(plans, store);
100
+ if (ctx !== void 0 && next !== void 0) return handler(ctx, next);
101
+ return handler;
102
+ }
103
+
104
+ // src/middlewares/billing.ts
105
+ import { setApiResponse as setApiResponse2, HTTP as HTTP2 } from "@fonderie/core";
106
+
107
+ // src/config.ts
108
+ var MESSAGE_KEYS = {
109
+ limitWarning: "billing.limit-warning",
110
+ limitReached: "billing.limit-reached",
111
+ limitBlocked: "billing.limit-blocked"
112
+ };
113
+
114
+ // src/services/policy.ts
115
+ function buildBillingContext(opts) {
116
+ const { subscriber, plan, active, counters } = opts;
117
+ const defaults = plan.defaults ?? {};
118
+ const statuses = {};
119
+ for (const [key, entry] of Object.entries(plan.policy ?? {})) {
120
+ if ("enabled" in entry) {
121
+ statuses[key] = { type: "feature", enabled: entry.enabled };
122
+ continue;
123
+ }
124
+ const { limit, buffer = defaults.buffer ?? 0, warnAt = defaults.warnAt ?? 0.8, window } = entry;
125
+ const used = counters[key] ?? 0;
126
+ const hardLimit = limit !== null ? limit + buffer : null;
127
+ let status = "ok";
128
+ if (hardLimit !== null && used >= hardLimit) status = "blocked";
129
+ else if (limit !== null && used >= limit) status = "over_limit";
130
+ else if (limit !== null && used >= limit * warnAt) status = "warning";
131
+ let resetsAt = null;
132
+ if (window) {
133
+ const windowMs = parseWindowMs(window);
134
+ const windowStart = Math.floor(Date.now() / windowMs) * windowMs;
135
+ resetsAt = new Date(windowStart + windowMs).toISOString();
136
+ }
137
+ statuses[key] = { type: "counter", limit, used, status, resetsAt };
138
+ }
139
+ return { subscriber, plan: plan.name, active, statuses };
140
+ }
141
+
142
+ // src/middlewares/billing.ts
143
+ var notified = /* @__PURE__ */ new Set();
144
+ function withBilling(store, config, backend) {
145
+ return async (ctx, next) => {
146
+ const subscriber = resolveSubscriber(ctx);
147
+ if (!subscriber) return next();
148
+ const subscription = await getSubscription(subscriber.type, subscriber.id, store);
149
+ const planName = subscription?.plan ?? config.plans[0]?.name ?? "free";
150
+ const active = !subscription || subscription.status === "active" || subscription.status === "trialing";
151
+ const plan = config.plans.find((p) => p.name === planName) ?? config.plans[0];
152
+ if (!plan) return next();
153
+ const counters = {};
154
+ for (const [key, entry] of Object.entries(plan.policy ?? {})) {
155
+ if ("enabled" in entry || !entry.window) continue;
156
+ const windowMs = parseWindowMs(entry.window);
157
+ const counterKey = `${subscriber.type}:${subscriber.id}:${key}`;
158
+ counters[key] = await backend.increment(counterKey, windowMs);
159
+ }
160
+ const billingCtx = buildBillingContext({ subscriber, plan, active, counters });
161
+ ctx.meta["billing"] = billingCtx;
162
+ for (const [key, status] of Object.entries(billingCtx.statuses)) {
163
+ if (status.type === "counter" && status.status === "blocked") {
164
+ return setApiResponse2(
165
+ HTTP2.TOO_MANY_REQUESTS,
166
+ "RATE_LIMIT_EXCEEDED",
167
+ `Limit exceeded for: ${key}`,
168
+ { key, limit: status.limit, used: status.used, resetsAt: status.resetsAt }
169
+ );
170
+ }
171
+ }
172
+ if (config.notifications) {
173
+ const toNotify = [];
174
+ const recipient = {
175
+ email: ctx.user?.email ?? null,
176
+ phone: null,
177
+ deviceToken: null
178
+ };
179
+ for (const [key, status] of Object.entries(billingCtx.statuses)) {
180
+ if (status.type !== "counter" || status.limit === null) continue;
181
+ const base = `${subscriber.type}:${subscriber.id}:${key}`;
182
+ if (config.notifications.softHit && status.status === "over_limit") {
183
+ const nk = `${base}:reached`;
184
+ if (!notified.has(nk)) {
185
+ notified.add(nk);
186
+ toNotify.push({
187
+ type: MESSAGE_KEYS.limitReached,
188
+ recipient,
189
+ data: {
190
+ key,
191
+ plan: plan.name,
192
+ limit: status.limit,
193
+ used: status.used
194
+ }
195
+ });
196
+ }
197
+ } else if (config.notifications.warnAt && status.status === "warning") {
198
+ const nk = `${base}:warning`;
199
+ if (!notified.has(nk)) {
200
+ notified.add(nk);
201
+ toNotify.push({
202
+ type: MESSAGE_KEYS.limitWarning,
203
+ recipient,
204
+ data: {
205
+ key,
206
+ plan: plan.name,
207
+ limit: status.limit,
208
+ used: status.used
209
+ }
210
+ });
211
+ }
212
+ }
213
+ }
214
+ if (toNotify.length > 0) {
215
+ const existing = ctx.meta["messages"];
216
+ ctx.meta["messages"] = [...existing ?? [], ...toNotify];
217
+ }
218
+ }
219
+ return next();
220
+ };
221
+ }
222
+ export {
223
+ requirePlan,
224
+ withBilling
225
+ };
226
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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\tamount: number;\n\tpriceId?: string;\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}\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"]}
@@ -0,0 +1,7 @@
1
+ // src/migrations/index.ts
2
+ import { createMigrationsPath } from "@fonderie/store";
3
+ var getMigrationsPath = () => createMigrationsPath(import.meta.url);
4
+ export {
5
+ getMigrationsPath
6
+ };
7
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/migrations/index.ts"],"sourcesContent":["import { createMigrationsPath } from '@fonderie/store';\n\nexport const getMigrationsPath = (): string => createMigrationsPath(import.meta.url);\n"],"mappings":";AAAA,SAAS,4BAA4B;AAE9B,IAAM,oBAAoB,MAAc,qBAAqB,YAAY,GAAG;","names":[]}
@@ -0,0 +1,38 @@
1
+ CREATE TABLE IF NOT EXISTS fonderie_plans (
2
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3
+ name TEXT NOT NULL UNIQUE,
4
+ seats INT,
5
+ trial_days INT NOT NULL DEFAULT 0,
6
+ monthly_amount INT,
7
+ monthly_price_id TEXT,
8
+ yearly_amount INT,
9
+ yearly_price_id TEXT,
10
+ active BOOLEAN NOT NULL DEFAULT true,
11
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
12
+ );
13
+
14
+ CREATE TABLE IF NOT EXISTS fonderie_subscriptions (
15
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
16
+ workspace_id UUID NOT NULL UNIQUE,
17
+ plan TEXT NOT NULL,
18
+ interval TEXT NOT NULL DEFAULT 'month',
19
+ status TEXT NOT NULL DEFAULT 'incomplete',
20
+ provider_customer_id TEXT,
21
+ provider_subscription_id TEXT,
22
+ current_period_start TIMESTAMPTZ,
23
+ current_period_end TIMESTAMPTZ,
24
+ cancel_at_period_end BOOLEAN NOT NULL DEFAULT false,
25
+ trial_ends_at TIMESTAMPTZ,
26
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
27
+ );
28
+
29
+ CREATE TABLE IF NOT EXISTS fonderie_usage_records (
30
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
31
+ workspace_id UUID NOT NULL,
32
+ metric TEXT NOT NULL,
33
+ quantity INT NOT NULL DEFAULT 1,
34
+ recorded_at TIMESTAMPTZ NOT NULL DEFAULT now()
35
+ );
36
+
37
+ CREATE INDEX IF NOT EXISTS fonderie_usage_records_workspace_metric_idx
38
+ ON fonderie_usage_records (workspace_id, metric, recorded_at);
@@ -0,0 +1,5 @@
1
+ ALTER TABLE fonderie_plans
2
+ ADD COLUMN IF NOT EXISTS description TEXT,
3
+ ADD COLUMN IF NOT EXISTS tier INT NOT NULL DEFAULT 0,
4
+ ADD COLUMN IF NOT EXISTS features JSONB NOT NULL DEFAULT '[]',
5
+ ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}';
@@ -0,0 +1 @@
1
+ ALTER TABLE fonderie_plans DROP COLUMN IF EXISTS limits;
@@ -0,0 +1,49 @@
1
+ -- fonderie_subscriptions: replace workspace_id with polymorphic subscriber
2
+ ALTER TABLE fonderie_subscriptions
3
+ ADD COLUMN subscriber_type TEXT,
4
+ ADD COLUMN subscriber_id UUID;
5
+
6
+ UPDATE fonderie_subscriptions
7
+ SET subscriber_type = 'workspace',
8
+ subscriber_id = workspace_id;
9
+
10
+ ALTER TABLE fonderie_subscriptions
11
+ ALTER COLUMN subscriber_type SET NOT NULL,
12
+ ALTER COLUMN subscriber_id SET NOT NULL;
13
+
14
+ ALTER TABLE fonderie_subscriptions
15
+ DROP CONSTRAINT fonderie_subscriptions_workspace_id_key;
16
+
17
+ ALTER TABLE fonderie_subscriptions
18
+ DROP COLUMN workspace_id;
19
+
20
+ ALTER TABLE fonderie_subscriptions
21
+ ADD CONSTRAINT fonderie_subscriptions_subscriber_type_check
22
+ CHECK (subscriber_type IN ('user', 'workspace')),
23
+ ADD CONSTRAINT fonderie_subscriptions_subscriber_unique
24
+ UNIQUE (subscriber_type, subscriber_id);
25
+
26
+ -- fonderie_usage_records: replace workspace_id with polymorphic subscriber
27
+ ALTER TABLE fonderie_usage_records
28
+ ADD COLUMN subscriber_type TEXT,
29
+ ADD COLUMN subscriber_id UUID;
30
+
31
+ UPDATE fonderie_usage_records
32
+ SET subscriber_type = 'workspace',
33
+ subscriber_id = workspace_id;
34
+
35
+ ALTER TABLE fonderie_usage_records
36
+ ALTER COLUMN subscriber_type SET NOT NULL,
37
+ ALTER COLUMN subscriber_id SET NOT NULL;
38
+
39
+ ALTER TABLE fonderie_usage_records
40
+ DROP COLUMN workspace_id;
41
+
42
+ ALTER TABLE fonderie_usage_records
43
+ ADD CONSTRAINT fonderie_usage_records_subscriber_type_check
44
+ CHECK (subscriber_type IN ('user', 'workspace'));
45
+
46
+ DROP INDEX IF EXISTS fonderie_usage_records_workspace_metric_idx;
47
+
48
+ CREATE INDEX fonderie_usage_records_subscriber_metric_idx
49
+ ON fonderie_usage_records (subscriber_type, subscriber_id, metric, recorded_at);
@@ -0,0 +1,13 @@
1
+ -- Tracks which threshold notifications have been sent per subscriber/key/window.
2
+ -- Prevents duplicate emails when a subscriber hovers around a threshold.
3
+ CREATE TABLE IF NOT EXISTS fonderie_billing_notifications (
4
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
5
+ subscriber_type TEXT NOT NULL,
6
+ subscriber_id UUID NOT NULL,
7
+ policy_key TEXT NOT NULL,
8
+ notification TEXT NOT NULL, -- 'warning' | 'reached' | 'blocked'
9
+ window_key TEXT NOT NULL, -- e.g. '2026-05-13' for a 1-day window
10
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
11
+ CONSTRAINT fonderie_billing_notifications_unique
12
+ UNIQUE (subscriber_type, subscriber_id, policy_key, notification, window_key)
13
+ );
package/dist/types.cjs ADDED
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __copyProps = (to, from, except, desc) => {
7
+ if (from && typeof from === "object" || typeof from === "function") {
8
+ for (let key of __getOwnPropNames(from))
9
+ if (!__hasOwnProp.call(to, key) && key !== except)
10
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
11
+ }
12
+ return to;
13
+ };
14
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
15
+
16
+ // src/types.ts
17
+ var types_exports = {};
18
+ module.exports = __toCommonJS(types_exports);
19
+ //# sourceMappingURL=types.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/types.ts"],"sourcesContent":["export type SubscriberType = 'user' | 'workspace';\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;","names":[]}
@@ -0,0 +1,76 @@
1
+ type SubscriberType = 'user' | 'workspace';
2
+ type PolicyEntry = {
3
+ enabled: boolean;
4
+ } | {
5
+ limit: number | null;
6
+ buffer?: number;
7
+ warnAt?: number;
8
+ window?: string;
9
+ unit?: string;
10
+ };
11
+ type LimitStatus = 'ok' | 'warning' | 'over_limit' | 'blocked';
12
+ type IPolicyStatus = {
13
+ type: 'feature';
14
+ enabled: boolean;
15
+ } | {
16
+ type: 'counter';
17
+ limit: number | null;
18
+ used: number;
19
+ status: LimitStatus;
20
+ resetsAt: string | null;
21
+ };
22
+ interface IBillingContext {
23
+ subscriber: {
24
+ type: SubscriberType;
25
+ id: string;
26
+ };
27
+ plan: string;
28
+ active: boolean;
29
+ statuses: Record<string, IPolicyStatus>;
30
+ }
31
+ interface ISubscription {
32
+ id: string;
33
+ subscriberType: SubscriberType;
34
+ subscriberId: string;
35
+ plan: string;
36
+ interval: 'month' | 'year';
37
+ status: SubscriptionStatus;
38
+ providerCustomerId: string | null;
39
+ providerSubscriptionId: string | null;
40
+ currentPeriodStart: string | null;
41
+ currentPeriodEnd: string | null;
42
+ cancelAtPeriodEnd: boolean;
43
+ trialEndsAt: string | null;
44
+ createdAt: string;
45
+ }
46
+ type SubscriptionStatus = 'trialing' | 'active' | 'past_due' | 'canceled' | 'incomplete' | 'paused';
47
+ interface IPlan {
48
+ id: string;
49
+ name: string;
50
+ seats: number | null;
51
+ trialDays: number;
52
+ monthlyAmount: number | null;
53
+ monthlyPriceId: string | null;
54
+ yearlyAmount: number | null;
55
+ yearlyPriceId: string | null;
56
+ description: string | null;
57
+ tier: number;
58
+ features: IPlanFeature[];
59
+ metadata: Record<string, unknown>;
60
+ }
61
+ interface IPlanFeature {
62
+ name: string;
63
+ description: string;
64
+ enabled: boolean;
65
+ limit?: number;
66
+ }
67
+ interface IUsageRecord {
68
+ id: string;
69
+ subscriberType: SubscriberType;
70
+ subscriberId: string;
71
+ metric: string;
72
+ quantity: number;
73
+ recordedAt: string;
74
+ }
75
+
76
+ export type { IBillingContext, IPlan, IPlanFeature, IPolicyStatus, ISubscription, IUsageRecord, LimitStatus, PolicyEntry, SubscriberType, SubscriptionStatus };
@@ -0,0 +1,76 @@
1
+ type SubscriberType = 'user' | 'workspace';
2
+ type PolicyEntry = {
3
+ enabled: boolean;
4
+ } | {
5
+ limit: number | null;
6
+ buffer?: number;
7
+ warnAt?: number;
8
+ window?: string;
9
+ unit?: string;
10
+ };
11
+ type LimitStatus = 'ok' | 'warning' | 'over_limit' | 'blocked';
12
+ type IPolicyStatus = {
13
+ type: 'feature';
14
+ enabled: boolean;
15
+ } | {
16
+ type: 'counter';
17
+ limit: number | null;
18
+ used: number;
19
+ status: LimitStatus;
20
+ resetsAt: string | null;
21
+ };
22
+ interface IBillingContext {
23
+ subscriber: {
24
+ type: SubscriberType;
25
+ id: string;
26
+ };
27
+ plan: string;
28
+ active: boolean;
29
+ statuses: Record<string, IPolicyStatus>;
30
+ }
31
+ interface ISubscription {
32
+ id: string;
33
+ subscriberType: SubscriberType;
34
+ subscriberId: string;
35
+ plan: string;
36
+ interval: 'month' | 'year';
37
+ status: SubscriptionStatus;
38
+ providerCustomerId: string | null;
39
+ providerSubscriptionId: string | null;
40
+ currentPeriodStart: string | null;
41
+ currentPeriodEnd: string | null;
42
+ cancelAtPeriodEnd: boolean;
43
+ trialEndsAt: string | null;
44
+ createdAt: string;
45
+ }
46
+ type SubscriptionStatus = 'trialing' | 'active' | 'past_due' | 'canceled' | 'incomplete' | 'paused';
47
+ interface IPlan {
48
+ id: string;
49
+ name: string;
50
+ seats: number | null;
51
+ trialDays: number;
52
+ monthlyAmount: number | null;
53
+ monthlyPriceId: string | null;
54
+ yearlyAmount: number | null;
55
+ yearlyPriceId: string | null;
56
+ description: string | null;
57
+ tier: number;
58
+ features: IPlanFeature[];
59
+ metadata: Record<string, unknown>;
60
+ }
61
+ interface IPlanFeature {
62
+ name: string;
63
+ description: string;
64
+ enabled: boolean;
65
+ limit?: number;
66
+ }
67
+ interface IUsageRecord {
68
+ id: string;
69
+ subscriberType: SubscriberType;
70
+ subscriberId: string;
71
+ metric: string;
72
+ quantity: number;
73
+ recordedAt: string;
74
+ }
75
+
76
+ export type { IBillingContext, IPlan, IPlanFeature, IPolicyStatus, ISubscription, IUsageRecord, LimitStatus, PolicyEntry, SubscriberType, SubscriptionStatus };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
package/package.json ADDED
@@ -0,0 +1,86 @@
1
+ {
2
+ "name": "@fonderie/billing",
3
+ "version": "1.0.0",
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
+ "keywords": [
6
+ "fonderie-js",
7
+ "billing",
8
+ "stripe",
9
+ "subscriptions",
10
+ "plans",
11
+ "usage-metering",
12
+ "payments",
13
+ "saas",
14
+ "typescript"
15
+ ],
16
+ "license": "MIT",
17
+ "type": "module",
18
+ "engines": {
19
+ "node": ">=20"
20
+ },
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "import": "./dist/index.js",
25
+ "require": "./dist/index.cjs"
26
+ },
27
+ "./types": {
28
+ "types": "./dist/types.d.ts",
29
+ "import": "./dist/types.js",
30
+ "require": "./dist/types.cjs"
31
+ },
32
+ "./middleware": {
33
+ "types": "./dist/middlewares/index.d.ts",
34
+ "import": "./dist/middlewares/index.js",
35
+ "require": "./dist/middlewares/index.cjs"
36
+ },
37
+ "./migrations": {
38
+ "types": "./dist/migrations/index.d.ts",
39
+ "import": "./dist/migrations/index.js"
40
+ }
41
+ },
42
+ "main": "./dist/index.cjs",
43
+ "module": "./dist/index.js",
44
+ "types": "./dist/index.d.ts",
45
+ "scripts": {
46
+ "build": "tsup && node -e \"require('node:fs').cpSync('src/migrations/sql', 'dist/migrations/sql', {recursive:true})\"",
47
+ "dev": "tsup --watch",
48
+ "typecheck": "tsc --noEmit",
49
+ "test": "tsx --test src/__tests__/*.test.ts",
50
+ "lint": "biome lint src",
51
+ "format": "biome format --write src",
52
+ "check": "biome check --write src"
53
+ },
54
+ "optionalDependencies": {
55
+ "stripe": "^22.1.1"
56
+ },
57
+ "peerDependencies": {
58
+ "@fonderie/core": "^0.1.0",
59
+ "@fonderie/store": "^0.1.0"
60
+ },
61
+ "devDependencies": {
62
+ "@fonderie/core": "../core",
63
+ "@fonderie/store": "../store",
64
+ "@types/node": "^25.6.0",
65
+ "tsup": "^8.5.1",
66
+ "tsx": "^4.21.0",
67
+ "typescript": "^6.0.3"
68
+ },
69
+ "publishConfig": {
70
+ "access": "public"
71
+ },
72
+ "files": [
73
+ "dist",
74
+ "LICENSE",
75
+ "README.md"
76
+ ],
77
+ "repository": {
78
+ "type": "git",
79
+ "url": "git+https://github.com/fonderie-js/sdk.git",
80
+ "directory": "packages/billing"
81
+ },
82
+ "homepage": "https://github.com/fonderie-js/sdk/tree/main/packages/billing#readme",
83
+ "bugs": {
84
+ "url": "https://github.com/fonderie-js/sdk/issues"
85
+ }
86
+ }