@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 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,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?: number;
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
- type BillingInterval = (typeof BILLING_INTERVAL)[keyof typeof BILLING_INTERVAL];
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: number;
233
+ unitAmount: bigint;
161
234
  currency: string;
162
- interval: 'month' | 'year';
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: 'month' | 'year';
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 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 {
200
287
  id: string;
201
288
  subscriberType: SubscriberType;
202
289
  subscriberId: string;
203
- metric: string;
204
- quantity: number;
205
- 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;
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 IUsageRecordDTO {
374
+ interface IWalletDTO {
375
+ balance: string;
376
+ currency: string;
377
+ precision: number;
378
+ }
379
+
380
+ interface IWalletTransactionDTO {
276
381
  id: string;
277
- subscriberType: SubscriberType;
278
- subscriberId: string;
279
- metric: string;
280
- quantity: number;
281
- 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;
282
390
  }
283
391
 
284
392
  function toPlanDTO(plan: IPlan): IPlanDTO
285
393
 
286
394
  function toSubscriptionDTO(sub: ISubscription): ISubscriptionDTO
287
395
 
288
- 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
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: '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,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 cents the seed value written to fonderie_plans and the
92
- * fallback shown (flagged pricingStale) when hydration is off or Stripe is
93
- * unreachable. When hydration resolves a live price, the live amount wins.
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?: number;
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 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 };