@fonderie/billing 1.1.0 → 1.1.2
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.
- package/brain/outcomes.md +91 -0
- package/brain/signatures.md +268 -0
- package/dist/migrations/sql/001_billing.sql +38 -0
- package/dist/migrations/sql/002_billing_plan_fields.sql +5 -0
- package/dist/migrations/sql/003_drop_limits.sql +1 -0
- package/dist/migrations/sql/004_polymorphic_subscribers.sql +49 -0
- package/dist/migrations/sql/005_billing_notifications.sql +13 -0
- package/package.json +5 -4
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<!-- GENERATED — do not edit. Regenerate with: npm run docs:signatures -->
|
|
2
|
+
|
|
3
|
+
# @fonderie/billing — outcomes
|
|
4
|
+
|
|
5
|
+
What this package does to a running app: tables its migrations create,
|
|
6
|
+
rows it seeds, routes it registers. Generated from the migration SQL and
|
|
7
|
+
route tables in source — trust this file instead of reading `dist/` or
|
|
8
|
+
downloading tarballs.
|
|
9
|
+
|
|
10
|
+
## Database tables (after all migrations)
|
|
11
|
+
|
|
12
|
+
### `fonderie_billing_notifications`
|
|
13
|
+
|
|
14
|
+
```sql
|
|
15
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
|
|
16
|
+
subscriber_type TEXT NOT NULL
|
|
17
|
+
subscriber_id UUID NOT NULL
|
|
18
|
+
policy_key TEXT NOT NULL
|
|
19
|
+
notification TEXT NOT NULL
|
|
20
|
+
window_key TEXT NOT NULL
|
|
21
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
22
|
+
-- CONSTRAINT fonderie_billing_notifications_unique UNIQUE (subscriber_type, subscriber_id, policy_key, notification, window_key)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### `fonderie_plans`
|
|
26
|
+
|
|
27
|
+
```sql
|
|
28
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
|
|
29
|
+
name TEXT NOT NULL UNIQUE
|
|
30
|
+
seats INT
|
|
31
|
+
trial_days INT NOT NULL DEFAULT 0
|
|
32
|
+
monthly_amount INT
|
|
33
|
+
monthly_price_id TEXT
|
|
34
|
+
yearly_amount INT
|
|
35
|
+
yearly_price_id TEXT
|
|
36
|
+
active BOOLEAN NOT NULL DEFAULT true
|
|
37
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
38
|
+
description TEXT
|
|
39
|
+
tier INT NOT NULL DEFAULT 0
|
|
40
|
+
features JSONB NOT NULL DEFAULT '[]'
|
|
41
|
+
metadata JSONB NOT NULL DEFAULT '{}'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### `fonderie_subscriptions`
|
|
45
|
+
|
|
46
|
+
```sql
|
|
47
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
|
|
48
|
+
plan TEXT NOT NULL
|
|
49
|
+
interval TEXT NOT NULL DEFAULT 'month'
|
|
50
|
+
status TEXT NOT NULL DEFAULT 'incomplete'
|
|
51
|
+
provider_customer_id TEXT
|
|
52
|
+
provider_subscription_id TEXT
|
|
53
|
+
current_period_start TIMESTAMPTZ
|
|
54
|
+
current_period_end TIMESTAMPTZ
|
|
55
|
+
cancel_at_period_end BOOLEAN NOT NULL DEFAULT false
|
|
56
|
+
trial_ends_at TIMESTAMPTZ
|
|
57
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
58
|
+
subscriber_type TEXT NOT NULL
|
|
59
|
+
subscriber_id UUID NOT NULL
|
|
60
|
+
CONSTRAINT fonderie_subscriptions_subscriber_unique UNIQUE (subscriber_type, subscriber_id)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### `fonderie_usage_records`
|
|
64
|
+
|
|
65
|
+
```sql
|
|
66
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
|
|
67
|
+
metric TEXT NOT NULL
|
|
68
|
+
quantity INT NOT NULL DEFAULT 1
|
|
69
|
+
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
70
|
+
subscriber_type TEXT NOT NULL
|
|
71
|
+
subscriber_id UUID NOT NULL
|
|
72
|
+
CONSTRAINT fonderie_usage_records_subscriber_type_check CHECK (subscriber_type IN ('user', 'workspace'))
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Raw SQL ships in `node_modules/@fonderie/billing/dist/migrations/sql/` — read it there if you must; never download tarballs.
|
|
76
|
+
|
|
77
|
+
## HTTP routes registered
|
|
78
|
+
|
|
79
|
+
| Method | Path | Middleware chain (auth / validation / handler) |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| POST | `/billing/checkout` | `requireAuth → validate(checkoutSchema) → checkout.createSession` |
|
|
82
|
+
| POST | `/billing/portal` | `requireAuth → checkout.createPortal` |
|
|
83
|
+
| GET | `/billing/subscription` | `requireAuth → subscription.get` |
|
|
84
|
+
| POST | `/billing/usage` | `requireAuth → validate(recordUsageSchema) → usage.record` |
|
|
85
|
+
| GET | `/billing/usage/:metric` | `requireAuth → usage.get` |
|
|
86
|
+
| POST | `/billing/webhook` | `webhook.handle` |
|
|
87
|
+
| GET | `/plans` | `plan.list` |
|
|
88
|
+
| POST | `/plans` | `validate(createPlanSchema) → plan.create` |
|
|
89
|
+
| DELETE | `/plans/:planId` | `plan.delete` |
|
|
90
|
+
| GET | `/plans/:planId` | `plan.get` |
|
|
91
|
+
| PUT | `/plans/:planId` | `validate(updatePlanSchema) → plan.update` |
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
<!-- GENERATED — do not edit. Regenerate with: npm run docs:signatures -->
|
|
2
|
+
|
|
3
|
+
# @fonderie/billing — signatures
|
|
4
|
+
|
|
5
|
+
## @fonderie/billing
|
|
6
|
+
|
|
7
|
+
Subpath exports: `@fonderie/billing/types`, `@fonderie/billing/middleware`, `@fonderie/billing/migrations`
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
new BillingModule(store: IStoreAdapter, config: IBillingConfig): BillingModule
|
|
11
|
+
.name: "@fonderie/billing"
|
|
12
|
+
.deps: string[]
|
|
13
|
+
.install(app: IFonderieApp): Promise<void>
|
|
14
|
+
|
|
15
|
+
new StripeProvider(secretKey: string, webhookSecret?: string | undefined): StripeProvider
|
|
16
|
+
.name: "stripe"
|
|
17
|
+
.createCustomer(opts: { email: string; subscriberType: SubscriberType; subscriberId: string; userId: string; }): Promise<{ customerId: string; }>
|
|
18
|
+
.createCheckoutSession(opts: { customerId: string; priceId: string; subscriberType: SubscriberType; subscriberId: string; trialDays?: number; successUrl: string; cancelUrl: string; }): Promise<{ url: string; }>
|
|
19
|
+
.createPortalSession(opts: { customerId: string; returnUrl: string; }): Promise<{ url: string; }>
|
|
20
|
+
.constructEvent(opts: { payload: string; signature: string; secret: string; }): Promise<IBillingEvent>
|
|
21
|
+
|
|
22
|
+
function requirePlan(plans: string | string[], store: IStoreAdapter): Middleware
|
|
23
|
+
|
|
24
|
+
function withBilling(store: IStoreAdapter, config: IBillingConfig, backend: ICounterBackend): Middleware
|
|
25
|
+
|
|
26
|
+
function hasFeature(ctx: IFonderieContext, key: string): boolean
|
|
27
|
+
|
|
28
|
+
function getPlanLimit(ctx: IFonderieContext, key: string): number | null
|
|
29
|
+
|
|
30
|
+
function getLimitStatus(ctx: IFonderieContext, key: string): IPolicyStatus | null
|
|
31
|
+
|
|
32
|
+
function requireFeature(key: string): Middleware
|
|
33
|
+
|
|
34
|
+
const MESSAGE_KEYS: { readonly limitWarning: "billing.limit-warning"; readonly limitReached: "billing.limit-reached"; readonly limitBlocked: "billing.limit-blocked"; }
|
|
35
|
+
|
|
36
|
+
interface IBillingConfig {
|
|
37
|
+
provider: IBillingProvider;
|
|
38
|
+
plans: IBillingPlan[];
|
|
39
|
+
successUrl: string;
|
|
40
|
+
cancelUrl: string;
|
|
41
|
+
webhookSecret?: string;
|
|
42
|
+
rateLimit?: {
|
|
43
|
+
backend?: RateLimitBackendConfig;
|
|
44
|
+
};
|
|
45
|
+
notifications?: IBillingNotificationsConfig;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface IBillingPlan {
|
|
49
|
+
name: string;
|
|
50
|
+
description?: string;
|
|
51
|
+
tier?: number;
|
|
52
|
+
trialDays?: number;
|
|
53
|
+
monthly?: IBillingPlanPrice;
|
|
54
|
+
yearly?: IBillingPlanPrice;
|
|
55
|
+
defaults?: IBillingPlanDefaults;
|
|
56
|
+
policy?: Record<string, PolicyEntry>;
|
|
57
|
+
metadata?: Record<string, unknown>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface IBillingPlanDefaults {
|
|
61
|
+
warnAt?: number;
|
|
62
|
+
buffer?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type RateLimitBackendConfig = 'memory' | 'db' | ICounterBackend;
|
|
66
|
+
|
|
67
|
+
interface IBillingNotificationsConfig {
|
|
68
|
+
warnAt?: boolean;
|
|
69
|
+
softHit?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type BillingMessageKey = (typeof MESSAGE_KEYS)[keyof typeof MESSAGE_KEYS];
|
|
73
|
+
|
|
74
|
+
new MemoryCounterBackend(): MemoryCounterBackend
|
|
75
|
+
.increment(key: string, windowMs: number | null, quantity?: number): Promise<number>
|
|
76
|
+
.get(key: string, windowMs: number | null): Promise<number>
|
|
77
|
+
|
|
78
|
+
new DBCounterBackend(store: IStoreAdapter): DBCounterBackend
|
|
79
|
+
.increment(key: string, windowMs: number | null, quantity?: number): Promise<number>
|
|
80
|
+
.get(key: string, windowMs: number | null): Promise<number>
|
|
81
|
+
|
|
82
|
+
interface ICounterBackend {
|
|
83
|
+
increment(key: string, windowMs: number | null, quantity?: number): Promise<number>;
|
|
84
|
+
get(key: string, windowMs: number | null): Promise<number>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface IBillingProvider {
|
|
88
|
+
name: string;
|
|
89
|
+
createCustomer(opts: {
|
|
90
|
+
email: string;
|
|
91
|
+
subscriberType: SubscriberType;
|
|
92
|
+
subscriberId: string;
|
|
93
|
+
userId: string;
|
|
94
|
+
}): Promise<{
|
|
95
|
+
customerId: string;
|
|
96
|
+
}>;
|
|
97
|
+
createCheckoutSession(opts: {
|
|
98
|
+
customerId: string;
|
|
99
|
+
priceId: string;
|
|
100
|
+
subscriberType: SubscriberType;
|
|
101
|
+
subscriberId: string;
|
|
102
|
+
trialDays?: number;
|
|
103
|
+
successUrl: string;
|
|
104
|
+
cancelUrl: string;
|
|
105
|
+
}): Promise<{
|
|
106
|
+
url: string;
|
|
107
|
+
}>;
|
|
108
|
+
createPortalSession(opts: {
|
|
109
|
+
customerId: string;
|
|
110
|
+
returnUrl: string;
|
|
111
|
+
}): Promise<{
|
|
112
|
+
url: string;
|
|
113
|
+
}>;
|
|
114
|
+
constructEvent(opts: {
|
|
115
|
+
payload: string;
|
|
116
|
+
signature: string;
|
|
117
|
+
secret: string;
|
|
118
|
+
}): Promise<IBillingEvent>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface IBillingEvent {
|
|
122
|
+
type: string;
|
|
123
|
+
subscription: INormalizedSubscription | null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface IPlan {
|
|
127
|
+
id: string;
|
|
128
|
+
name: string;
|
|
129
|
+
seats: number | null;
|
|
130
|
+
trialDays: number;
|
|
131
|
+
monthlyAmount: number | null;
|
|
132
|
+
monthlyPriceId: string | null;
|
|
133
|
+
yearlyAmount: number | null;
|
|
134
|
+
yearlyPriceId: string | null;
|
|
135
|
+
description: string | null;
|
|
136
|
+
tier: number;
|
|
137
|
+
features: IPlanFeature[];
|
|
138
|
+
metadata: Record<string, unknown>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface ISubscription {
|
|
142
|
+
id: string;
|
|
143
|
+
subscriberType: SubscriberType;
|
|
144
|
+
subscriberId: string;
|
|
145
|
+
plan: string;
|
|
146
|
+
interval: 'month' | 'year';
|
|
147
|
+
status: SubscriptionStatus;
|
|
148
|
+
providerCustomerId: string | null;
|
|
149
|
+
providerSubscriptionId: string | null;
|
|
150
|
+
currentPeriodStart: string | null;
|
|
151
|
+
currentPeriodEnd: string | null;
|
|
152
|
+
cancelAtPeriodEnd: boolean;
|
|
153
|
+
trialEndsAt: string | null;
|
|
154
|
+
createdAt: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
interface IUsageRecord {
|
|
158
|
+
id: string;
|
|
159
|
+
subscriberType: SubscriberType;
|
|
160
|
+
subscriberId: string;
|
|
161
|
+
metric: string;
|
|
162
|
+
quantity: number;
|
|
163
|
+
recordedAt: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
type SubscriptionStatus = 'trialing' | 'active' | 'past_due' | 'canceled' | 'incomplete' | 'paused';
|
|
167
|
+
|
|
168
|
+
type PolicyEntry = {
|
|
169
|
+
enabled: boolean;
|
|
170
|
+
} | {
|
|
171
|
+
limit: number | null;
|
|
172
|
+
buffer?: number;
|
|
173
|
+
warnAt?: number;
|
|
174
|
+
window?: string;
|
|
175
|
+
unit?: string;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
type LimitStatus = 'ok' | 'warning' | 'over_limit' | 'blocked';
|
|
179
|
+
|
|
180
|
+
type IPolicyStatus = {
|
|
181
|
+
type: 'feature';
|
|
182
|
+
enabled: boolean;
|
|
183
|
+
} | {
|
|
184
|
+
type: 'counter';
|
|
185
|
+
limit: number | null;
|
|
186
|
+
used: number;
|
|
187
|
+
status: LimitStatus;
|
|
188
|
+
resetsAt: string | null;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
interface IBillingContext {
|
|
192
|
+
subscriber: {
|
|
193
|
+
type: SubscriberType;
|
|
194
|
+
id: string;
|
|
195
|
+
};
|
|
196
|
+
plan: string;
|
|
197
|
+
active: boolean;
|
|
198
|
+
statuses: Record<string, IPolicyStatus>;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
interface IPlanDTO {
|
|
202
|
+
id: string;
|
|
203
|
+
planId: string;
|
|
204
|
+
name: string;
|
|
205
|
+
description: string;
|
|
206
|
+
tier: number;
|
|
207
|
+
seats: number | null;
|
|
208
|
+
trialDays: number;
|
|
209
|
+
pricing: {
|
|
210
|
+
monthly: number;
|
|
211
|
+
yearly: number;
|
|
212
|
+
currency: string;
|
|
213
|
+
};
|
|
214
|
+
features: IPlanFeature[];
|
|
215
|
+
metadata: Record<string, unknown>;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
interface ISubscriptionDTO {
|
|
219
|
+
id: string;
|
|
220
|
+
subscriberType: SubscriberType;
|
|
221
|
+
subscriberId: string;
|
|
222
|
+
plan: string;
|
|
223
|
+
interval: string;
|
|
224
|
+
status: string;
|
|
225
|
+
cancelAtPeriodEnd: boolean;
|
|
226
|
+
currentPeriodStart: string | null;
|
|
227
|
+
currentPeriodEnd: string | null;
|
|
228
|
+
trialEndsAt: string | null;
|
|
229
|
+
createdAt: string;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
interface IUsageRecordDTO {
|
|
233
|
+
id: string;
|
|
234
|
+
subscriberType: SubscriberType;
|
|
235
|
+
subscriberId: string;
|
|
236
|
+
metric: string;
|
|
237
|
+
quantity: number;
|
|
238
|
+
recordedAt: string;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function toPlanDTO(plan: IPlan): IPlanDTO
|
|
242
|
+
|
|
243
|
+
function toSubscriptionDTO(sub: ISubscription): ISubscriptionDTO
|
|
244
|
+
|
|
245
|
+
function toUsageRecordDTO(record: IUsageRecord): IUsageRecordDTO
|
|
246
|
+
|
|
247
|
+
function recordUsage(opts: { subscriberType: SubscriberType; subscriberId: string; metric: string; quantity: number; }, store: IStoreAdapter): Promise<void>
|
|
248
|
+
|
|
249
|
+
function getUsage(subscriberType: SubscriberType, subscriberId: string, metric: string, since: Date, store: IStoreAdapter): Promise<number>
|
|
250
|
+
|
|
251
|
+
function getPlans(config: IBillingConfig): IBillingPlan[]
|
|
252
|
+
|
|
253
|
+
function getPlanByName(name: string, config: IBillingConfig): IBillingPlan | null
|
|
254
|
+
|
|
255
|
+
function getDBPlans(store: IStoreAdapter): Promise<IPlan[]>
|
|
256
|
+
|
|
257
|
+
function getPlanById(id: string, store: IStoreAdapter): Promise<IPlan | null>
|
|
258
|
+
|
|
259
|
+
function createPlan(data: { name: string; description?: string | null; tier?: number; seats?: number | null; trialDays?: number; features?: unknown; metadata?: unknown; monthlyAmount?: number | null; monthlyPriceId?: string | null; yearlyAmount?: number | null; yearlyPriceId?: string | null; }, store: IStoreAdapter): Promise<...>
|
|
260
|
+
|
|
261
|
+
function updatePlan(id: string, data: Partial<Omit<IPlan, "id">>, store: IStoreAdapter): Promise<IPlan | null>
|
|
262
|
+
|
|
263
|
+
function deletePlan(id: string, store: IStoreAdapter): Promise<boolean>
|
|
264
|
+
|
|
265
|
+
function getSubscription(subscriberType: SubscriberType, subscriberId: string, store: IStoreAdapter): Promise<ISubscription | null>
|
|
266
|
+
|
|
267
|
+
namespace schemas — exports: checkoutSchema, createPlanSchema, recordUsageSchema, updatePlanSchema
|
|
268
|
+
```
|
|
@@ -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 @@
|
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fonderie/billing",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "SaaS billing in one module — config-driven plan catalogue, Stripe subscriptions, polymorphic user and workspace billing surfaces, usage metering, and webhook handling.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"fonderie-js",
|
|
@@ -71,17 +71,18 @@
|
|
|
71
71
|
},
|
|
72
72
|
"files": [
|
|
73
73
|
"dist",
|
|
74
|
+
"brain",
|
|
74
75
|
"LICENSE",
|
|
75
76
|
"README.md"
|
|
76
77
|
],
|
|
77
78
|
"repository": {
|
|
78
79
|
"type": "git",
|
|
79
|
-
"url": "git+https://github.com/
|
|
80
|
+
"url": "git+https://github.com/fonderiejs/sdk.git",
|
|
80
81
|
"directory": "packages/billing"
|
|
81
82
|
},
|
|
82
|
-
"homepage": "https://github.com/
|
|
83
|
+
"homepage": "https://github.com/fonderiejs/sdk/tree/main/packages/billing#readme",
|
|
83
84
|
"bugs": {
|
|
84
|
-
"url": "https://github.com/
|
|
85
|
+
"url": "https://github.com/fonderiejs/sdk/issues"
|
|
85
86
|
},
|
|
86
87
|
"dependencies": {
|
|
87
88
|
"zod": "^4.4.3"
|