@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 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`
@@ -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: 'month' | 'year';
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 IUsageRecord {
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
- metric: string;
162
- quantity: number;
163
- recordedAt: string;
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 IUsageRecordDTO {
374
+ interface IWalletDTO {
375
+ balance: string;
376
+ currency: string;
377
+ precision: number;
378
+ }
379
+
380
+ interface IWalletTransactionDTO {
233
381
  id: string;
234
- subscriberType: SubscriberType;
235
- subscriberId: string;
236
- metric: string;
237
- quantity: number;
238
- recordedAt: string;
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 toUsageRecordDTO(record: IUsageRecord): IUsageRecordDTO
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: 'month' | 'year';
33
+ interval: BillingInterval;
25
34
  }
26
35
  interface IResolvedPrice {
27
36
  priceId: string;
28
37
  lookupKey: string | null;
29
- unitAmount: number;
38
+ unitAmount: bigint;
30
39
  currency: string;
31
- interval: 'month' | 'year';
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
- * @deprecated Display amount in cents. When pricing hydration is on and the
92
- * price resolves from Stripe, the live amount wins; this is the fallback only.
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?: number;
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), use the deprecated hardcoded amount/USD path. */
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 ICounterBackend as d, type IBillingPlan as e, type IBillingNotificationsConfig as f, type IBillingPlanDefaults as g, type IBillingPlanPrice as h, type IBillingPricingConfig as i, requirePlan as r, withBilling as w };
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 };