@fonderie/billing 5.3.0 → 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 +230 -13
- package/dist/{index-CBhthuMn.d.ts → index-BdNYDuhk.d.ts} +108 -9
- package/dist/{index-CS1QagwE.d.cts → index-Ca4pXx07.d.cts} +108 -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,10 @@ 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<...>
|
|
20
|
+
.resolvePriceById(priceId: string): Promise<IResolvedPrice | null>
|
|
21
|
+
.resolvePricesByLookupKey(lookupKeys: string[]): Promise<Map<string, IResolvedPrice>>
|
|
22
|
+
.updateSubscription(opts: { subscriptionId: string; priceId: string; }): Promise<{ status: string; currentPeriodStart: Date | null; currentPeriodEnd: Date | null; }>
|
|
19
23
|
.createPortalSession(opts: { customerId: string; returnUrl: string; }): Promise<{ url: string; }>
|
|
20
24
|
.constructEvent(opts: { payload: string; signature: string; secret: string; }): Promise<IBillingEvent>
|
|
21
25
|
|
|
@@ -31,6 +35,16 @@ function getLimitStatus(ctx: IFonderieContext, key: string): IPolicyStatus | nul
|
|
|
31
35
|
|
|
32
36
|
function requireFeature(key: string): Middleware
|
|
33
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
|
+
|
|
34
48
|
const MESSAGE_KEYS: { readonly limitWarning: "billing.limit-warning"; readonly limitReached: "billing.limit-reached"; readonly limitBlocked: "billing.limit-blocked"; }
|
|
35
49
|
|
|
36
50
|
interface IBillingConfig {
|
|
@@ -43,6 +57,19 @@ interface IBillingConfig {
|
|
|
43
57
|
backend?: RateLimitBackendConfig;
|
|
44
58
|
};
|
|
45
59
|
notifications?: IBillingNotificationsConfig;
|
|
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>;
|
|
46
73
|
}
|
|
47
74
|
|
|
48
75
|
interface IBillingPlan {
|
|
@@ -54,6 +81,7 @@ interface IBillingPlan {
|
|
|
54
81
|
yearly?: IBillingPlanPrice;
|
|
55
82
|
defaults?: IBillingPlanDefaults;
|
|
56
83
|
policy?: Record<string, PolicyEntry>;
|
|
84
|
+
wallet?: IBillingPlanWallet;
|
|
57
85
|
metadata?: Record<string, unknown>;
|
|
58
86
|
}
|
|
59
87
|
|
|
@@ -62,6 +90,36 @@ interface IBillingPlanDefaults {
|
|
|
62
90
|
buffer?: number;
|
|
63
91
|
}
|
|
64
92
|
|
|
93
|
+
interface IBillingPlanPrice {
|
|
94
|
+
lookupKey?: string;
|
|
95
|
+
priceId?: string;
|
|
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>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface IBillingPricingConfig {
|
|
109
|
+
hydration?: boolean;
|
|
110
|
+
cacheTtlMs?: number;
|
|
111
|
+
transferGraceMs?: number;
|
|
112
|
+
maxStaleMs?: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface IBillingWalletConfig {
|
|
116
|
+
currency?: string;
|
|
117
|
+
precision?: number;
|
|
118
|
+
adminToken?: string;
|
|
119
|
+
webhookSecret?: string;
|
|
120
|
+
creditPacks?: IBillingCreditPack[];
|
|
121
|
+
}
|
|
122
|
+
|
|
65
123
|
type RateLimitBackendConfig = 'memory' | 'db' | ICounterBackend;
|
|
66
124
|
|
|
67
125
|
interface IBillingNotificationsConfig {
|
|
@@ -84,6 +142,18 @@ interface ICounterBackend {
|
|
|
84
142
|
get(key: string, windowMs: number | null): Promise<number>;
|
|
85
143
|
}
|
|
86
144
|
|
|
145
|
+
const BILLING_INTERVAL: { readonly MONTH: "month"; readonly YEAR: "year"; }
|
|
146
|
+
|
|
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];
|
|
156
|
+
|
|
87
157
|
interface IBillingProvider {
|
|
88
158
|
name: string;
|
|
89
159
|
createCustomer(opts: {
|
|
@@ -105,6 +175,30 @@ interface IBillingProvider {
|
|
|
105
175
|
}): Promise<{
|
|
106
176
|
url: string;
|
|
107
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
|
+
}>;
|
|
192
|
+
resolvePriceById(priceId: string): Promise<IResolvedPrice | null>;
|
|
193
|
+
resolvePricesByLookupKey(lookupKeys: string[]): Promise<Map<string, IResolvedPrice>>;
|
|
194
|
+
updateSubscription(opts: {
|
|
195
|
+
subscriptionId: string;
|
|
196
|
+
priceId: string;
|
|
197
|
+
}): Promise<{
|
|
198
|
+
status: string;
|
|
199
|
+
currentPeriodStart: Date | null;
|
|
200
|
+
currentPeriodEnd: Date | null;
|
|
201
|
+
}>;
|
|
108
202
|
createPortalSession(opts: {
|
|
109
203
|
customerId: string;
|
|
110
204
|
returnUrl: string;
|
|
@@ -121,6 +215,27 @@ interface IBillingProvider {
|
|
|
121
215
|
interface IBillingEvent {
|
|
122
216
|
type: string;
|
|
123
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>;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
interface IResolvedPrice {
|
|
231
|
+
priceId: string;
|
|
232
|
+
lookupKey: string | null;
|
|
233
|
+
unitAmount: bigint;
|
|
234
|
+
currency: string;
|
|
235
|
+
interval: BillingInterval;
|
|
236
|
+
nickname: string | null;
|
|
237
|
+
productId: string;
|
|
238
|
+
active: boolean;
|
|
124
239
|
}
|
|
125
240
|
|
|
126
241
|
interface IPlan {
|
|
@@ -143,7 +258,7 @@ interface ISubscription {
|
|
|
143
258
|
subscriberType: SubscriberType;
|
|
144
259
|
subscriberId: string;
|
|
145
260
|
plan: string;
|
|
146
|
-
interval:
|
|
261
|
+
interval: BillingInterval;
|
|
147
262
|
status: SubscriptionStatus;
|
|
148
263
|
providerCustomerId: string | null;
|
|
149
264
|
providerSubscriptionId: string | null;
|
|
@@ -154,13 +269,38 @@ interface ISubscription {
|
|
|
154
269
|
createdAt: string;
|
|
155
270
|
}
|
|
156
271
|
|
|
157
|
-
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 {
|
|
158
287
|
id: string;
|
|
159
288
|
subscriberType: SubscriberType;
|
|
160
289
|
subscriberId: string;
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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;
|
|
164
304
|
}
|
|
165
305
|
|
|
166
306
|
type SubscriptionStatus = 'trialing' | 'active' | 'past_due' | 'canceled' | 'incomplete' | 'paused';
|
|
@@ -196,6 +336,7 @@ interface IBillingContext {
|
|
|
196
336
|
plan: string;
|
|
197
337
|
active: boolean;
|
|
198
338
|
statuses: Record<string, IPolicyStatus>;
|
|
339
|
+
wallet?: IWalletContext;
|
|
199
340
|
}
|
|
200
341
|
|
|
201
342
|
interface IPlanDTO {
|
|
@@ -211,6 +352,7 @@ interface IPlanDTO {
|
|
|
211
352
|
yearly: number;
|
|
212
353
|
currency: string;
|
|
213
354
|
};
|
|
355
|
+
pricingStale?: boolean;
|
|
214
356
|
features: IPlanFeature[];
|
|
215
357
|
metadata: Record<string, unknown>;
|
|
216
358
|
}
|
|
@@ -229,20 +371,95 @@ interface ISubscriptionDTO {
|
|
|
229
371
|
createdAt: string;
|
|
230
372
|
}
|
|
231
373
|
|
|
232
|
-
interface
|
|
374
|
+
interface IWalletDTO {
|
|
375
|
+
balance: string;
|
|
376
|
+
currency: string;
|
|
377
|
+
precision: number;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
interface IWalletTransactionDTO {
|
|
233
381
|
id: string;
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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;
|
|
239
390
|
}
|
|
240
391
|
|
|
241
392
|
function toPlanDTO(plan: IPlan): IPlanDTO
|
|
242
393
|
|
|
243
394
|
function toSubscriptionDTO(sub: ISubscription): ISubscriptionDTO
|
|
244
395
|
|
|
245
|
-
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
|
|
246
463
|
|
|
247
464
|
function recordUsage(opts: { subscriberType: SubscriberType; subscriberId: string; metric: string; quantity: number; }, store: IStoreAdapter): Promise<void>
|
|
248
465
|
|
|
@@ -264,5 +481,5 @@ function deletePlan(id: string, store: IStoreAdapter): Promise<boolean>
|
|
|
264
481
|
|
|
265
482
|
function getSubscription(subscriberType: SubscriberType, subscriberId: string, store: IStoreAdapter): Promise<ISubscription | null>
|
|
266
483
|
|
|
267
|
-
namespace schemas — exports: checkoutSchema, createPlanSchema, recordUsageSchema, updatePlanSchema
|
|
484
|
+
namespace schemas — exports: checkoutSchema, createPlanSchema, grantWalletSchema, recordUsageSchema, updatePlanSchema, walletCheckoutSchema
|
|
268
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,10 +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
|
-
*
|
|
92
|
-
*
|
|
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.
|
|
93
118
|
*/
|
|
94
|
-
amount?:
|
|
119
|
+
amount?: bigint;
|
|
95
120
|
}
|
|
96
121
|
/**
|
|
97
122
|
* Read-through pricing: amount/currency come from Stripe (source of truth) rather
|
|
@@ -99,7 +124,7 @@ interface IBillingPlanPrice {
|
|
|
99
124
|
* See packages/billing/docs/pricing-hydration.md.
|
|
100
125
|
*/
|
|
101
126
|
interface IBillingPricingConfig {
|
|
102
|
-
/** Kill-switch. When false (default),
|
|
127
|
+
/** Kill-switch. When false (default), serve the configured amount/USD directly. */
|
|
103
128
|
hydration?: boolean;
|
|
104
129
|
/** Fresh-cache TTL. Default 300_000 (5m). */
|
|
105
130
|
cacheTtlMs?: number;
|
|
@@ -112,6 +137,28 @@ interface IBillingPlanDefaults {
|
|
|
112
137
|
warnAt?: number;
|
|
113
138
|
buffer?: number;
|
|
114
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
|
+
}
|
|
115
162
|
interface IBillingPlan {
|
|
116
163
|
name: string;
|
|
117
164
|
description?: string;
|
|
@@ -121,6 +168,7 @@ interface IBillingPlan {
|
|
|
121
168
|
yearly?: IBillingPlanPrice;
|
|
122
169
|
defaults?: IBillingPlanDefaults;
|
|
123
170
|
policy?: Record<string, PolicyEntry>;
|
|
171
|
+
wallet?: IBillingPlanWallet;
|
|
124
172
|
metadata?: Record<string, unknown>;
|
|
125
173
|
}
|
|
126
174
|
type RateLimitBackendConfig = 'memory' | 'db' | ICounterBackend;
|
|
@@ -128,6 +176,56 @@ interface IBillingNotificationsConfig {
|
|
|
128
176
|
warnAt?: boolean;
|
|
129
177
|
softHit?: boolean;
|
|
130
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
|
+
}
|
|
131
229
|
interface IBillingConfig {
|
|
132
230
|
provider: IBillingProvider;
|
|
133
231
|
plans: IBillingPlan[];
|
|
@@ -139,6 +237,7 @@ interface IBillingConfig {
|
|
|
139
237
|
};
|
|
140
238
|
notifications?: IBillingNotificationsConfig;
|
|
141
239
|
pricing?: IBillingPricingConfig;
|
|
240
|
+
wallet?: IBillingWalletConfig;
|
|
142
241
|
}
|
|
143
242
|
declare const MESSAGE_KEYS: {
|
|
144
243
|
readonly limitWarning: "billing.limit-warning";
|
|
@@ -152,4 +251,4 @@ declare function requirePlan(plans: string | string[], store: IStoreAdapter, ctx
|
|
|
152
251
|
|
|
153
252
|
declare function withBilling(store: IStoreAdapter, config: IBillingConfig, backend: ICounterBackend): Middleware;
|
|
154
253
|
|
|
155
|
-
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 };
|