@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.
- package/README.md +34 -0
- package/brain/outcomes.md +71 -0
- package/brain/signatures.md +191 -17
- package/dist/{index-Byy5mBE4.d.ts → index-BdNYDuhk.d.ts} +107 -9
- package/dist/{index-DjAGcrSi.d.cts → index-Ca4pXx07.d.cts} +107 -9
- package/dist/index.cjs +1097 -146
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +137 -15
- package/dist/index.d.ts +137 -15
- package/dist/index.js +1076 -145
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs +180 -0
- package/dist/middlewares/index.cjs.map +1 -1
- package/dist/middlewares/index.d.cts +1 -1
- package/dist/middlewares/index.d.ts +1 -1
- package/dist/middlewares/index.js +180 -0
- package/dist/middlewares/index.js.map +1 -1
- package/dist/migrations/sql/006_wallet.sql +85 -0
- package/dist/types.cjs +17 -3
- package/dist/types.cjs.map +1 -1
- package/dist/types.d.cts +34 -7
- package/dist/types.d.ts +34 -7
- package/dist/types.js +13 -2
- package/dist/types.js.map +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -30,6 +30,40 @@ import { requirePlan, requireFeature, hasFeature, getPlanLimit } from '@fonderie
|
|
|
30
30
|
`StripeProvider` handles checkout and webhook events; usage counters run
|
|
31
31
|
on `MemoryCounterBackend` or `DBCounterBackend`.
|
|
32
32
|
|
|
33
|
+
## Stored-value wallet (opt-in)
|
|
34
|
+
|
|
35
|
+
Setting `wallet` on the billing config turns on a ledger-backed credit
|
|
36
|
+
wallet: subscribers hold a balance (`bigint`, smallest currency unit), buy
|
|
37
|
+
config-defined credit packs through one-time provider checkout, and plans
|
|
38
|
+
price metered actions in credits.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
new BillingModule(store, {
|
|
42
|
+
provider: new StripeProvider(secretKey),
|
|
43
|
+
successUrl, cancelUrl, webhookSecret,
|
|
44
|
+
wallet: {
|
|
45
|
+
currency: 'USD',
|
|
46
|
+
webhookSecret: process.env.STRIPE_PAYMENT_WEBHOOK_SECRET, // separate endpoint
|
|
47
|
+
creditPacks: [{ id: 'small', name: 'Small pack', credits: 5000n, priceAmount: 499n }],
|
|
48
|
+
},
|
|
49
|
+
plans: [{
|
|
50
|
+
name: 'payg',
|
|
51
|
+
wallet: {
|
|
52
|
+
grantAmount: 50n, // auto-granted lazily, once per period
|
|
53
|
+
rates: { 'sms:send': { cost: 75n, unit: 'msg' } },
|
|
54
|
+
},
|
|
55
|
+
}],
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Every mutation goes through the append-only ledger with an idempotency
|
|
60
|
+
key — the balance table is a cache, debits are atomic (`FOR UPDATE` plus a
|
|
61
|
+
conditional-update floor), and webhook replays are no-ops. In routes:
|
|
62
|
+
`requireWalletBalance('sms:send')` gates on affordability; inside the unit
|
|
63
|
+
of work, `debitWalletForMetric(ctx, 'sms:send', { idempotencyKey: taskId }, store)`
|
|
64
|
+
charges the plan rate exactly once. Wallet amounts cross HTTP as digit
|
|
65
|
+
strings (`IWalletDTO`).
|
|
66
|
+
|
|
33
67
|
## Why this exists
|
|
34
68
|
|
|
35
69
|
You've shipped this plumbing before — auth, teams, billing, messaging —
|
package/brain/outcomes.md
CHANGED
|
@@ -22,6 +22,20 @@ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
|
22
22
|
-- CONSTRAINT fonderie_billing_notifications_unique UNIQUE (subscriber_type, subscriber_id, policy_key, notification, window_key)
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
### `fonderie_credit_packs`
|
|
26
|
+
|
|
27
|
+
```sql
|
|
28
|
+
id TEXT PRIMARY KEY
|
|
29
|
+
name TEXT NOT NULL
|
|
30
|
+
currency TEXT NOT NULL DEFAULT 'USD'
|
|
31
|
+
credits BIGINT NOT NULL
|
|
32
|
+
price_amount BIGINT NOT NULL
|
|
33
|
+
price_id TEXT
|
|
34
|
+
active BOOLEAN NOT NULL DEFAULT true
|
|
35
|
+
metadata JSONB NOT NULL DEFAULT '{}'
|
|
36
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
37
|
+
```
|
|
38
|
+
|
|
25
39
|
### `fonderie_plans`
|
|
26
40
|
|
|
27
41
|
```sql
|
|
@@ -39,6 +53,7 @@ description TEXT
|
|
|
39
53
|
tier INT NOT NULL DEFAULT 0
|
|
40
54
|
features JSONB NOT NULL DEFAULT '[]'
|
|
41
55
|
metadata JSONB NOT NULL DEFAULT '{}'
|
|
56
|
+
wallet JSONB
|
|
42
57
|
```
|
|
43
58
|
|
|
44
59
|
### `fonderie_subscriptions`
|
|
@@ -72,6 +87,52 @@ subscriber_id UUID NOT NULL
|
|
|
72
87
|
CONSTRAINT fonderie_usage_records_subscriber_type_check CHECK (subscriber_type IN ('user', 'workspace'))
|
|
73
88
|
```
|
|
74
89
|
|
|
90
|
+
### `fonderie_wallet_balances`
|
|
91
|
+
|
|
92
|
+
```sql
|
|
93
|
+
subscriber_type TEXT NOT NULL
|
|
94
|
+
subscriber_id UUID NOT NULL
|
|
95
|
+
currency TEXT NOT NULL DEFAULT 'USD'
|
|
96
|
+
amount BIGINT NOT NULL DEFAULT 0
|
|
97
|
+
version BIGINT NOT NULL DEFAULT 1
|
|
98
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
99
|
+
-- CONSTRAINT fonderie_wallet_balances_subscriber_type_check CHECK (subscriber_type IN ('user', 'workspace'))
|
|
100
|
+
-- PRIMARY KEY (subscriber_type, subscriber_id, currency)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### `fonderie_wallet_grants`
|
|
104
|
+
|
|
105
|
+
```sql
|
|
106
|
+
subscriber_type TEXT NOT NULL
|
|
107
|
+
subscriber_id UUID NOT NULL
|
|
108
|
+
currency TEXT NOT NULL
|
|
109
|
+
period TEXT NOT NULL
|
|
110
|
+
amount BIGINT NOT NULL
|
|
111
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
112
|
+
-- CONSTRAINT fonderie_wallet_grants_subscriber_type_check CHECK (subscriber_type IN ('user', 'workspace'))
|
|
113
|
+
-- PRIMARY KEY (subscriber_type, subscriber_id, currency, period)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### `fonderie_wallet_ledger`
|
|
117
|
+
|
|
118
|
+
```sql
|
|
119
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
|
|
120
|
+
subscriber_type TEXT NOT NULL
|
|
121
|
+
subscriber_id UUID NOT NULL
|
|
122
|
+
currency TEXT NOT NULL DEFAULT 'USD'
|
|
123
|
+
type TEXT NOT NULL
|
|
124
|
+
amount BIGINT NOT NULL
|
|
125
|
+
balance_after BIGINT NOT NULL
|
|
126
|
+
description TEXT
|
|
127
|
+
idempotency_key TEXT NOT NULL UNIQUE
|
|
128
|
+
metadata JSONB NOT NULL DEFAULT '{}'
|
|
129
|
+
provider_tx_id TEXT
|
|
130
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
131
|
+
-- CONSTRAINT fonderie_wallet_ledger_subscriber_type_check CHECK (subscriber_type IN ('user', 'workspace'))
|
|
132
|
+
-- CONSTRAINT fonderie_wallet_ledger_type_check CHECK (type IN ('purchase', 'grant', 'usage', 'refund', 'adjustment'))
|
|
133
|
+
-- CONSTRAINT fonderie_wallet_ledger_amount_nonzero_check CHECK (amount <> 0)
|
|
134
|
+
```
|
|
135
|
+
|
|
75
136
|
Raw SQL ships in `node_modules/@fonderie/billing/dist/migrations/sql/` — read it there if you must; never download tarballs.
|
|
76
137
|
|
|
77
138
|
## HTTP routes registered
|
|
@@ -83,9 +144,19 @@ Raw SQL ships in `node_modules/@fonderie/billing/dist/migrations/sql/` — read
|
|
|
83
144
|
| GET | `/billing/subscription` | `requireAuth → subscription.get` |
|
|
84
145
|
| POST | `/billing/usage` | `requireAuth → validate(recordUsageSchema) → usage.record` |
|
|
85
146
|
| GET | `/billing/usage/:metric` | `requireAuth → usage.get` |
|
|
147
|
+
| GET | `/billing/wallet` | `requireAuth → wallet.get` |
|
|
148
|
+
| POST | `/billing/wallet/checkout` | `requireAuth → validate(walletCheckoutSchema) → wallet.checkout` |
|
|
149
|
+
| POST | `/billing/wallet/grant` | `requireAdminToken(config.wallet.adminToken) → validate(grantWalletSchema) → wallet.grant` |
|
|
150
|
+
| GET | `/billing/wallet/transactions` | `requireAuth → wallet.transactions` |
|
|
86
151
|
| POST | `/billing/webhook` | `webhook.handle` |
|
|
152
|
+
| POST | `/billing/webhook/payment` | `paymentWebhook.handle` |
|
|
87
153
|
| GET | `/plans` | `plan.list` |
|
|
88
154
|
| POST | `/plans` | `validate(createPlanSchema) → plan.create` |
|
|
89
155
|
| DELETE | `/plans/:planId` | `plan.delete` |
|
|
90
156
|
| GET | `/plans/:planId` | `plan.get` |
|
|
91
157
|
| PUT | `/plans/:planId` | `validate(updatePlanSchema) → plan.update` |
|
|
158
|
+
|
|
159
|
+
## Migration statements not replayed (verify in raw SQL)
|
|
160
|
+
|
|
161
|
+
- `fonderie_plans: ALTER COLUMN monthly_amount TYPE BIGINT`
|
|
162
|
+
- `fonderie_plans: ALTER COLUMN yearly_amount TYPE BIGINT`
|
package/brain/signatures.md
CHANGED
|
@@ -16,6 +16,7 @@ new StripeProvider(secretKey: string, webhookSecret?: string | undefined): Strip
|
|
|
16
16
|
.name: "stripe"
|
|
17
17
|
.createCustomer(opts: { email: string; subscriberType: SubscriberType; subscriberId: string; userId: string; }): Promise<{ customerId: string; }>
|
|
18
18
|
.createCheckoutSession(opts: { customerId: string; priceId: string; subscriberType: SubscriberType; subscriberId: string; trialDays?: number; successUrl: string; cancelUrl: string; }): Promise<{ url: string; }>
|
|
19
|
+
.createPaymentCheckoutSession(opts: { customerId: string; amount: bigint; currency: string; name: string; quantity?: number; priceId?: string; metadata: Record<string, string>; successUrl: string; cancelUrl: string; }): Promise<...>
|
|
19
20
|
.resolvePriceById(priceId: string): Promise<IResolvedPrice | null>
|
|
20
21
|
.resolvePricesByLookupKey(lookupKeys: string[]): Promise<Map<string, IResolvedPrice>>
|
|
21
22
|
.updateSubscription(opts: { subscriptionId: string; priceId: string; }): Promise<{ status: string; currentPeriodStart: Date | null; currentPeriodEnd: Date | null; }>
|
|
@@ -34,6 +35,16 @@ function getLimitStatus(ctx: IFonderieContext, key: string): IPolicyStatus | nul
|
|
|
34
35
|
|
|
35
36
|
function requireFeature(key: string): Middleware
|
|
36
37
|
|
|
38
|
+
function getWalletStatus(ctx: IFonderieContext): IWalletContext | null
|
|
39
|
+
|
|
40
|
+
function getWalletRate(ctx: IFonderieContext, metric: string): bigint | null
|
|
41
|
+
|
|
42
|
+
function requireWalletBalance(metric: string): Middleware
|
|
43
|
+
|
|
44
|
+
function debitWalletForMetric(ctx: IFonderieContext, metric: string, opts: { idempotencyKey: string; quantity?: number; description?: string; metadata?: Record<string, unknown>; }, store: IStoreAdapter): Promise<...>
|
|
45
|
+
|
|
46
|
+
function insufficientCreditsResponse(err: InsufficientFundsError, metric?: string | undefined): Response
|
|
47
|
+
|
|
37
48
|
const MESSAGE_KEYS: { readonly limitWarning: "billing.limit-warning"; readonly limitReached: "billing.limit-reached"; readonly limitBlocked: "billing.limit-blocked"; }
|
|
38
49
|
|
|
39
50
|
interface IBillingConfig {
|
|
@@ -47,6 +58,18 @@ interface IBillingConfig {
|
|
|
47
58
|
};
|
|
48
59
|
notifications?: IBillingNotificationsConfig;
|
|
49
60
|
pricing?: IBillingPricingConfig;
|
|
61
|
+
wallet?: IBillingWalletConfig;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface IBillingCreditPack {
|
|
65
|
+
id: string;
|
|
66
|
+
name: string;
|
|
67
|
+
credits: bigint;
|
|
68
|
+
priceAmount: bigint;
|
|
69
|
+
currency?: string;
|
|
70
|
+
priceId?: string;
|
|
71
|
+
active?: boolean;
|
|
72
|
+
metadata?: Record<string, unknown>;
|
|
50
73
|
}
|
|
51
74
|
|
|
52
75
|
interface IBillingPlan {
|
|
@@ -58,6 +81,7 @@ interface IBillingPlan {
|
|
|
58
81
|
yearly?: IBillingPlanPrice;
|
|
59
82
|
defaults?: IBillingPlanDefaults;
|
|
60
83
|
policy?: Record<string, PolicyEntry>;
|
|
84
|
+
wallet?: IBillingPlanWallet;
|
|
61
85
|
metadata?: Record<string, unknown>;
|
|
62
86
|
}
|
|
63
87
|
|
|
@@ -69,7 +93,16 @@ interface IBillingPlanDefaults {
|
|
|
69
93
|
interface IBillingPlanPrice {
|
|
70
94
|
lookupKey?: string;
|
|
71
95
|
priceId?: string;
|
|
72
|
-
amount?:
|
|
96
|
+
amount?: bigint;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface IBillingPlanWallet {
|
|
100
|
+
currency?: string;
|
|
101
|
+
precision?: number;
|
|
102
|
+
grantAmount?: bigint;
|
|
103
|
+
grantPeriod?: 'month' | 'week' | 'day';
|
|
104
|
+
overdraftLimit?: bigint;
|
|
105
|
+
rates?: Record<string, IWalletRate>;
|
|
73
106
|
}
|
|
74
107
|
|
|
75
108
|
interface IBillingPricingConfig {
|
|
@@ -79,6 +112,14 @@ interface IBillingPricingConfig {
|
|
|
79
112
|
maxStaleMs?: number;
|
|
80
113
|
}
|
|
81
114
|
|
|
115
|
+
interface IBillingWalletConfig {
|
|
116
|
+
currency?: string;
|
|
117
|
+
precision?: number;
|
|
118
|
+
adminToken?: string;
|
|
119
|
+
webhookSecret?: string;
|
|
120
|
+
creditPacks?: IBillingCreditPack[];
|
|
121
|
+
}
|
|
122
|
+
|
|
82
123
|
type RateLimitBackendConfig = 'memory' | 'db' | ICounterBackend;
|
|
83
124
|
|
|
84
125
|
interface IBillingNotificationsConfig {
|
|
@@ -103,7 +144,15 @@ interface ICounterBackend {
|
|
|
103
144
|
|
|
104
145
|
const BILLING_INTERVAL: { readonly MONTH: "month"; readonly YEAR: "year"; }
|
|
105
146
|
|
|
106
|
-
|
|
147
|
+
const BILLING_INTERVALS: readonly ["month", "year"]
|
|
148
|
+
|
|
149
|
+
function isBillingInterval(value: unknown): value is "month" | "year"
|
|
150
|
+
|
|
151
|
+
const WALLET_LEDGER_TYPES: readonly ["purchase", "grant", "usage", "refund", "adjustment"]
|
|
152
|
+
|
|
153
|
+
type BillingInterval = (typeof BILLING_INTERVALS)[number];
|
|
154
|
+
|
|
155
|
+
type WalletLedgerType = (typeof WALLET_LEDGER_TYPES)[number];
|
|
107
156
|
|
|
108
157
|
interface IBillingProvider {
|
|
109
158
|
name: string;
|
|
@@ -126,6 +175,20 @@ interface IBillingProvider {
|
|
|
126
175
|
}): Promise<{
|
|
127
176
|
url: string;
|
|
128
177
|
}>;
|
|
178
|
+
createPaymentCheckoutSession?(opts: {
|
|
179
|
+
customerId: string;
|
|
180
|
+
amount: bigint;
|
|
181
|
+
currency: string;
|
|
182
|
+
name: string;
|
|
183
|
+
quantity?: number;
|
|
184
|
+
priceId?: string;
|
|
185
|
+
metadata: Record<string, string>;
|
|
186
|
+
successUrl: string;
|
|
187
|
+
cancelUrl: string;
|
|
188
|
+
}): Promise<{
|
|
189
|
+
url: string;
|
|
190
|
+
sessionId: string;
|
|
191
|
+
}>;
|
|
129
192
|
resolvePriceById(priceId: string): Promise<IResolvedPrice | null>;
|
|
130
193
|
resolvePricesByLookupKey(lookupKeys: string[]): Promise<Map<string, IResolvedPrice>>;
|
|
131
194
|
updateSubscription(opts: {
|
|
@@ -152,14 +215,24 @@ interface IBillingProvider {
|
|
|
152
215
|
interface IBillingEvent {
|
|
153
216
|
type: string;
|
|
154
217
|
subscription: INormalizedSubscription | null;
|
|
218
|
+
payment?: INormalizedPayment | null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
interface INormalizedPayment {
|
|
222
|
+
sessionId: string;
|
|
223
|
+
providerTxId: string | null;
|
|
224
|
+
amountTotal: bigint | null;
|
|
225
|
+
currency: string | null;
|
|
226
|
+
paymentStatus: string | null;
|
|
227
|
+
metadata: Record<string, string>;
|
|
155
228
|
}
|
|
156
229
|
|
|
157
230
|
interface IResolvedPrice {
|
|
158
231
|
priceId: string;
|
|
159
232
|
lookupKey: string | null;
|
|
160
|
-
unitAmount:
|
|
233
|
+
unitAmount: bigint;
|
|
161
234
|
currency: string;
|
|
162
|
-
interval:
|
|
235
|
+
interval: BillingInterval;
|
|
163
236
|
nickname: string | null;
|
|
164
237
|
productId: string;
|
|
165
238
|
active: boolean;
|
|
@@ -185,7 +258,7 @@ interface ISubscription {
|
|
|
185
258
|
subscriberType: SubscriberType;
|
|
186
259
|
subscriberId: string;
|
|
187
260
|
plan: string;
|
|
188
|
-
interval:
|
|
261
|
+
interval: BillingInterval;
|
|
189
262
|
status: SubscriptionStatus;
|
|
190
263
|
providerCustomerId: string | null;
|
|
191
264
|
providerSubscriptionId: string | null;
|
|
@@ -196,13 +269,38 @@ interface ISubscription {
|
|
|
196
269
|
createdAt: string;
|
|
197
270
|
}
|
|
198
271
|
|
|
199
|
-
interface
|
|
272
|
+
interface IWalletBalance {
|
|
273
|
+
balance: bigint;
|
|
274
|
+
version: number;
|
|
275
|
+
updatedAt: string | null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
interface IWalletContext {
|
|
279
|
+
balance: bigint;
|
|
280
|
+
currency: string;
|
|
281
|
+
precision: number;
|
|
282
|
+
overdraftLimit: bigint;
|
|
283
|
+
rates: Record<string, IWalletRate>;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
interface IWalletLedgerEntry {
|
|
200
287
|
id: string;
|
|
201
288
|
subscriberType: SubscriberType;
|
|
202
289
|
subscriberId: string;
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
290
|
+
currency: string;
|
|
291
|
+
type: WalletLedgerType;
|
|
292
|
+
amount: bigint;
|
|
293
|
+
balanceAfter: bigint;
|
|
294
|
+
description: string | null;
|
|
295
|
+
idempotencyKey: string;
|
|
296
|
+
metadata: Record<string, unknown>;
|
|
297
|
+
providerTxId: string | null;
|
|
298
|
+
createdAt: string;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
interface IWalletRate {
|
|
302
|
+
cost: bigint;
|
|
303
|
+
unit?: string;
|
|
206
304
|
}
|
|
207
305
|
|
|
208
306
|
type SubscriptionStatus = 'trialing' | 'active' | 'past_due' | 'canceled' | 'incomplete' | 'paused';
|
|
@@ -238,6 +336,7 @@ interface IBillingContext {
|
|
|
238
336
|
plan: string;
|
|
239
337
|
active: boolean;
|
|
240
338
|
statuses: Record<string, IPolicyStatus>;
|
|
339
|
+
wallet?: IWalletContext;
|
|
241
340
|
}
|
|
242
341
|
|
|
243
342
|
interface IPlanDTO {
|
|
@@ -272,20 +371,95 @@ interface ISubscriptionDTO {
|
|
|
272
371
|
createdAt: string;
|
|
273
372
|
}
|
|
274
373
|
|
|
275
|
-
interface
|
|
374
|
+
interface IWalletDTO {
|
|
375
|
+
balance: string;
|
|
376
|
+
currency: string;
|
|
377
|
+
precision: number;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
interface IWalletTransactionDTO {
|
|
276
381
|
id: string;
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
382
|
+
type: WalletLedgerType;
|
|
383
|
+
amount: string;
|
|
384
|
+
balanceAfter: string;
|
|
385
|
+
currency: string;
|
|
386
|
+
description: string | null;
|
|
387
|
+
providerTxId: string | null;
|
|
388
|
+
metadata: Record<string, unknown>;
|
|
389
|
+
createdAt: string;
|
|
282
390
|
}
|
|
283
391
|
|
|
284
392
|
function toPlanDTO(plan: IPlan): IPlanDTO
|
|
285
393
|
|
|
286
394
|
function toSubscriptionDTO(sub: ISubscription): ISubscriptionDTO
|
|
287
395
|
|
|
288
|
-
function
|
|
396
|
+
function toWalletDTO(balance: bigint, currency: string, precision: number): IWalletDTO
|
|
397
|
+
|
|
398
|
+
function toWalletTransactionDTO(entry: IWalletLedgerEntry): IWalletTransactionDTO
|
|
399
|
+
|
|
400
|
+
function creditWallet(opts: IWalletSubscriber & { amount: bigint; idempotencyKey: string; type?: "purchase" | "grant" | "usage" | "refund" | "adjustment"; description?: string; metadata?: Record<...>; providerTxId?: string; }, store: IStoreAdapter): Promise<...>
|
|
401
|
+
|
|
402
|
+
function debitWallet(opts: IWalletSubscriber & { amount: bigint; idempotencyKey: string; type?: "purchase" | "grant" | "usage" | "refund" | "adjustment"; overdraftLimit?: bigint; description?: string; metadata?: Record<...>; }, store: IStoreAdapter): Promise<...>
|
|
403
|
+
|
|
404
|
+
function getWalletBalance(sub: IWalletSubscriber, store: IStoreAdapter): Promise<IWalletBalance>
|
|
405
|
+
|
|
406
|
+
function getWalletLedger(opts: IWalletSubscriber & { limit?: number; cursor?: { createdAt: string; id: string; }; }, store: IStoreAdapter): Promise<IWalletLedgerPage>
|
|
407
|
+
|
|
408
|
+
function ensurePeriodicGrant(opts: IWalletSubscriber & { amount: bigint; period: string; description?: string; }, store: IStoreAdapter): Promise<IGrantResult>
|
|
409
|
+
|
|
410
|
+
function currentGrantPeriod(period: "month" | "week" | "day", now?: Date): string
|
|
411
|
+
|
|
412
|
+
function resolvePlanWallet(plan: IBillingPlan, config: IBillingConfig): IResolvedPlanWallet | null
|
|
413
|
+
|
|
414
|
+
function encodeLedgerCursor(createdAt: string, id: string): string
|
|
415
|
+
|
|
416
|
+
function decodeLedgerCursor(cursor: string): { createdAt: string; id: string; } | null
|
|
417
|
+
|
|
418
|
+
interface IWalletSubscriber {
|
|
419
|
+
subscriberType: SubscriberType;
|
|
420
|
+
subscriberId: string;
|
|
421
|
+
currency: string;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
interface IWalletMutationResult {
|
|
425
|
+
balance: bigint;
|
|
426
|
+
duplicate: boolean;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
interface IWalletLedgerPage {
|
|
430
|
+
entries: IWalletLedgerEntry[];
|
|
431
|
+
nextCursor: string | null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
interface IGrantResult {
|
|
435
|
+
granted: boolean;
|
|
436
|
+
balance: bigint | null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
interface IResolvedPlanWallet {
|
|
440
|
+
currency: string;
|
|
441
|
+
precision: number;
|
|
442
|
+
overdraftLimit: bigint;
|
|
443
|
+
grantAmount: bigint | null;
|
|
444
|
+
grantPeriod: 'month' | 'week' | 'day';
|
|
445
|
+
rates: Record<string, IWalletRate>;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
new InsufficientFundsError(available: bigint, required: bigint, currency: string): InsufficientFundsError
|
|
449
|
+
.available: bigint
|
|
450
|
+
.required: bigint
|
|
451
|
+
.currency: string
|
|
452
|
+
.name: string
|
|
453
|
+
.message: string
|
|
454
|
+
.stack: string
|
|
455
|
+
.cause: unknown
|
|
456
|
+
|
|
457
|
+
new DuplicateTransactionError(idempotencyKey: string): DuplicateTransactionError
|
|
458
|
+
.idempotencyKey: string
|
|
459
|
+
.name: string
|
|
460
|
+
.message: string
|
|
461
|
+
.stack: string
|
|
462
|
+
.cause: unknown
|
|
289
463
|
|
|
290
464
|
function recordUsage(opts: { subscriberType: SubscriberType; subscriberId: string; metric: string; quantity: number; }, store: IStoreAdapter): Promise<void>
|
|
291
465
|
|
|
@@ -307,5 +481,5 @@ function deletePlan(id: string, store: IStoreAdapter): Promise<boolean>
|
|
|
307
481
|
|
|
308
482
|
function getSubscription(subscriberType: SubscriberType, subscriberId: string, store: IStoreAdapter): Promise<ISubscription | null>
|
|
309
483
|
|
|
310
|
-
namespace schemas — exports: checkoutSchema, createPlanSchema, recordUsageSchema, updatePlanSchema
|
|
484
|
+
namespace schemas — exports: checkoutSchema, createPlanSchema, grantWalletSchema, recordUsageSchema, updatePlanSchema, walletCheckoutSchema
|
|
311
485
|
```
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { Middleware, IFonderieContext } from '@fonderie/core';
|
|
2
2
|
import { IStoreAdapter } from '@fonderie/store';
|
|
3
|
-
import { SubscriberType, PolicyEntry } from './types.js';
|
|
3
|
+
import { SubscriberType, BillingInterval, PolicyEntry, IWalletRate } from './types.js';
|
|
4
4
|
|
|
5
5
|
interface IBillingEvent {
|
|
6
6
|
type: string;
|
|
7
7
|
subscription: INormalizedSubscription | null;
|
|
8
|
+
payment?: INormalizedPayment | null;
|
|
9
|
+
}
|
|
10
|
+
interface INormalizedPayment {
|
|
11
|
+
sessionId: string;
|
|
12
|
+
providerTxId: string | null;
|
|
13
|
+
amountTotal: bigint | null;
|
|
14
|
+
currency: string | null;
|
|
15
|
+
paymentStatus: string | null;
|
|
16
|
+
metadata: Record<string, string>;
|
|
8
17
|
}
|
|
9
18
|
interface INormalizedSubscription {
|
|
10
19
|
subscriberType: SubscriberType;
|
|
@@ -21,14 +30,14 @@ interface INormalizedSubscription {
|
|
|
21
30
|
currentPeriodEnd: Date;
|
|
22
31
|
cancelAtPeriodEnd: boolean;
|
|
23
32
|
trialEndsAt: Date | null;
|
|
24
|
-
interval:
|
|
33
|
+
interval: BillingInterval;
|
|
25
34
|
}
|
|
26
35
|
interface IResolvedPrice {
|
|
27
36
|
priceId: string;
|
|
28
37
|
lookupKey: string | null;
|
|
29
|
-
unitAmount:
|
|
38
|
+
unitAmount: bigint;
|
|
30
39
|
currency: string;
|
|
31
|
-
interval:
|
|
40
|
+
interval: BillingInterval;
|
|
32
41
|
nickname: string | null;
|
|
33
42
|
productId: string;
|
|
34
43
|
active: boolean;
|
|
@@ -54,6 +63,20 @@ interface IBillingProvider {
|
|
|
54
63
|
}): Promise<{
|
|
55
64
|
url: string;
|
|
56
65
|
}>;
|
|
66
|
+
createPaymentCheckoutSession?(opts: {
|
|
67
|
+
customerId: string;
|
|
68
|
+
amount: bigint;
|
|
69
|
+
currency: string;
|
|
70
|
+
name: string;
|
|
71
|
+
quantity?: number;
|
|
72
|
+
priceId?: string;
|
|
73
|
+
metadata: Record<string, string>;
|
|
74
|
+
successUrl: string;
|
|
75
|
+
cancelUrl: string;
|
|
76
|
+
}): Promise<{
|
|
77
|
+
url: string;
|
|
78
|
+
sessionId: string;
|
|
79
|
+
}>;
|
|
57
80
|
resolvePriceById(priceId: string): Promise<IResolvedPrice | null>;
|
|
58
81
|
resolvePricesByLookupKey(lookupKeys: string[]): Promise<Map<string, IResolvedPrice>>;
|
|
59
82
|
updateSubscription(opts: {
|
|
@@ -88,11 +111,12 @@ interface IBillingPlanPrice {
|
|
|
88
111
|
/** Stripe price id. Used for hydration and as a lookup_key fallback. */
|
|
89
112
|
priceId?: string;
|
|
90
113
|
/**
|
|
91
|
-
* Display amount in
|
|
92
|
-
*
|
|
93
|
-
*
|
|
114
|
+
* Display amount in the smallest currency unit (bigint, e.g. 1999n = $19.99) —
|
|
115
|
+
* the seed value written to fonderie_plans and the fallback shown (flagged
|
|
116
|
+
* pricingStale) when hydration is off or Stripe is unreachable. When
|
|
117
|
+
* hydration resolves a live price, the live amount wins.
|
|
94
118
|
*/
|
|
95
|
-
amount?:
|
|
119
|
+
amount?: bigint;
|
|
96
120
|
}
|
|
97
121
|
/**
|
|
98
122
|
* Read-through pricing: amount/currency come from Stripe (source of truth) rather
|
|
@@ -113,6 +137,28 @@ interface IBillingPlanDefaults {
|
|
|
113
137
|
warnAt?: number;
|
|
114
138
|
buffer?: number;
|
|
115
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* Per-plan wallet economics. Requires config.wallet to be set — a plan-level
|
|
142
|
+
* wallet without the global opt-in is ignored (with a boot warning).
|
|
143
|
+
*/
|
|
144
|
+
interface IBillingPlanWallet {
|
|
145
|
+
/** Overrides the global wallet currency for this plan's grants and rates. */
|
|
146
|
+
currency?: string;
|
|
147
|
+
/** Display precision override. */
|
|
148
|
+
precision?: number;
|
|
149
|
+
/**
|
|
150
|
+
* Credits auto-granted once per grantPeriod, applied lazily by withBilling
|
|
151
|
+
* on the subscriber's first request of the period. Only granted while the
|
|
152
|
+
* subscription is active or trialing (no new credit while payment fails).
|
|
153
|
+
*/
|
|
154
|
+
grantAmount?: bigint;
|
|
155
|
+
/** Grant cadence for grantAmount. Default 'month'. */
|
|
156
|
+
grantPeriod?: 'month' | 'week' | 'day';
|
|
157
|
+
/** How far below zero rate debits may take the balance. Default 0n (block at zero). */
|
|
158
|
+
overdraftLimit?: bigint;
|
|
159
|
+
/** Per-metric unit costs, e.g. { 'sms:send': { cost: 75n, unit: 'msg' } }. */
|
|
160
|
+
rates?: Record<string, IWalletRate>;
|
|
161
|
+
}
|
|
116
162
|
interface IBillingPlan {
|
|
117
163
|
name: string;
|
|
118
164
|
description?: string;
|
|
@@ -122,6 +168,7 @@ interface IBillingPlan {
|
|
|
122
168
|
yearly?: IBillingPlanPrice;
|
|
123
169
|
defaults?: IBillingPlanDefaults;
|
|
124
170
|
policy?: Record<string, PolicyEntry>;
|
|
171
|
+
wallet?: IBillingPlanWallet;
|
|
125
172
|
metadata?: Record<string, unknown>;
|
|
126
173
|
}
|
|
127
174
|
type RateLimitBackendConfig = 'memory' | 'db' | ICounterBackend;
|
|
@@ -129,6 +176,56 @@ interface IBillingNotificationsConfig {
|
|
|
129
176
|
warnAt?: boolean;
|
|
130
177
|
softHit?: boolean;
|
|
131
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* A purchasable credit top-up, synced to fonderie_credit_packs at boot (same
|
|
181
|
+
* pattern as plans). Purchases go through the provider's one-time checkout;
|
|
182
|
+
* the payment webhook credits `credits` to the buyer's wallet.
|
|
183
|
+
*/
|
|
184
|
+
interface IBillingCreditPack {
|
|
185
|
+
/** Stable identifier used by POST /billing/wallet/checkout, e.g. 'small'. */
|
|
186
|
+
id: string;
|
|
187
|
+
name: string;
|
|
188
|
+
/** Wallet credits granted on purchase, in the smallest wallet unit. */
|
|
189
|
+
credits: bigint;
|
|
190
|
+
/** Purchase price in the provider's smallest currency unit. */
|
|
191
|
+
priceAmount: bigint;
|
|
192
|
+
/**
|
|
193
|
+
* ISO 4217 PAYMENT currency for the provider charge; defaults to the
|
|
194
|
+
* buyer's wallet currency. Credits always land in the buyer's wallet
|
|
195
|
+
* currency regardless of what the charge was priced in.
|
|
196
|
+
*/
|
|
197
|
+
currency?: string;
|
|
198
|
+
/** Existing provider Price id — used instead of the ad-hoc priceAmount. */
|
|
199
|
+
priceId?: string;
|
|
200
|
+
/** Inactive packs stay in the DB but can no longer be checked out. */
|
|
201
|
+
active?: boolean;
|
|
202
|
+
metadata?: Record<string, unknown>;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Opt-in stored-value wallet. Presence of this object activates the wallet
|
|
206
|
+
* subsystem (routes, credit packs, per-plan grants and rates); leaving it out
|
|
207
|
+
* changes nothing for existing subscription-only consumers.
|
|
208
|
+
*/
|
|
209
|
+
interface IBillingWalletConfig {
|
|
210
|
+
/** Default wallet currency when a plan doesn't override it. Default 'USD'. */
|
|
211
|
+
currency?: string;
|
|
212
|
+
/** Display precision — decimal places of the smallest unit. Default 2. */
|
|
213
|
+
precision?: number;
|
|
214
|
+
/**
|
|
215
|
+
* Bearer token guarding POST /billing/wallet/grant (manual support/ops
|
|
216
|
+
* grants). The route is only registered when a token is configured.
|
|
217
|
+
*/
|
|
218
|
+
adminToken?: string;
|
|
219
|
+
/**
|
|
220
|
+
* Signing secret for POST /billing/webhook/payment. REQUIRED for pack
|
|
221
|
+
* purchases: the route answers 500 until it is set, and it deliberately
|
|
222
|
+
* does NOT fall back to the subscription webhook's secret — per-endpoint
|
|
223
|
+
* secrets keep a delivery captured for one endpoint from replaying
|
|
224
|
+
* against the other.
|
|
225
|
+
*/
|
|
226
|
+
webhookSecret?: string;
|
|
227
|
+
creditPacks?: IBillingCreditPack[];
|
|
228
|
+
}
|
|
132
229
|
interface IBillingConfig {
|
|
133
230
|
provider: IBillingProvider;
|
|
134
231
|
plans: IBillingPlan[];
|
|
@@ -140,6 +237,7 @@ interface IBillingConfig {
|
|
|
140
237
|
};
|
|
141
238
|
notifications?: IBillingNotificationsConfig;
|
|
142
239
|
pricing?: IBillingPricingConfig;
|
|
240
|
+
wallet?: IBillingWalletConfig;
|
|
143
241
|
}
|
|
144
242
|
declare const MESSAGE_KEYS: {
|
|
145
243
|
readonly limitWarning: "billing.limit-warning";
|
|
@@ -153,4 +251,4 @@ declare function requirePlan(plans: string | string[], store: IStoreAdapter, ctx
|
|
|
153
251
|
|
|
154
252
|
declare function withBilling(store: IStoreAdapter, config: IBillingConfig, backend: ICounterBackend): Middleware;
|
|
155
253
|
|
|
156
|
-
export { type BillingMessageKey as B, type IBillingConfig as I, MESSAGE_KEYS as M, type RateLimitBackendConfig as R, type IBillingProvider as a, type IResolvedPrice as b, type IBillingEvent as c, type
|
|
254
|
+
export { type BillingMessageKey as B, type IBillingConfig as I, MESSAGE_KEYS as M, type RateLimitBackendConfig as R, type IBillingProvider as a, type IResolvedPrice as b, type IBillingEvent as c, type IBillingPlan as d, type ICounterBackend as e, type IBillingCreditPack as f, type IBillingNotificationsConfig as g, type IBillingPlanDefaults as h, type IBillingPlanPrice as i, type IBillingPlanWallet as j, type IBillingPricingConfig as k, type IBillingWalletConfig as l, type INormalizedPayment as m, requirePlan as r, withBilling as w };
|