@basaltkit/subscriptions 1.0.1 → 2.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/dist/index.d.ts CHANGED
@@ -19,7 +19,12 @@ declare function meter(limit: number): Meter;
19
19
  type FeatureValue = boolean | number | Meter;
20
20
  type BillingPeriod = 'monthly' | 'yearly';
21
21
  interface PlanDefinition {
22
- /** 0 = free · number = same price both periods · object = per period · 'custom' = sales-led */
22
+ /**
23
+ * Price in the currency's minor unit (integer; cents — `2900` = $29.00).
24
+ * 0 = free · number = same price both periods · object = per period ·
25
+ * 'custom' = sales-led. Only gated (`> 0`) and displayed here; the actual
26
+ * Stripe charge uses a pre-created price id on the gateway.
27
+ */
23
28
  price: number | {
24
29
  monthly: number;
25
30
  yearly: number;
@@ -268,12 +273,16 @@ declare class FakeBillingGateway implements BillingGateway {
268
273
  * `BillingGateway` (card subscriptions). Recurring billing is modelled by
269
274
  * creating one payment per period (invoice → reference → webhook confirms → the
270
275
  * period is activated).
276
+ *
277
+ * **All amounts are integers in the currency's minor unit** (cents; `100 = 1.00`)
278
+ * — the Stripe convention. Use the `money` helpers (`toMinor`/`formatMoney`) at
279
+ * the human boundary. Drivers translate to each provider's expected format.
271
280
  */
272
281
  /** A one-off payment request handed to a `PaymentGateway`. */
273
282
  interface PaymentRequest {
274
283
  /** Who is paying — a tenant/user/customer id you reconcile against. */
275
284
  billableId: string;
276
- /** Amount in the currency's major unit (e.g. 5000 = 5000,00 Kz). */
285
+ /** Amount in the currency's minor unit (integer; `500000` = 5.000,00 Kz). */
277
286
  amount: number;
278
287
  /** ISO 4217. Defaults to the gateway's own (AOA for Angolan gateways). */
279
288
  currency?: string;
@@ -349,6 +358,246 @@ declare class FakePaymentGateway implements PaymentGateway {
349
358
  createPayment(request: PaymentRequest): Promise<PaymentInstruction>;
350
359
  verifyWebhook(rawBody: string, signature: string | undefined): PaymentEvent;
351
360
  }
361
+ type PaymentRecordStatus = 'pending' | 'paid' | 'failed';
362
+ /** A payment as tracked in your app's ledger, keyed by the gateway payment id. */
363
+ interface PaymentRecord {
364
+ /** Gateway payment id — `PaymentInstruction.id` / `PaymentEvent.paymentId`. */
365
+ id: string;
366
+ status: PaymentRecordStatus;
367
+ amount: number;
368
+ billableId?: string;
369
+ reference?: string;
370
+ /** Epoch ms. */
371
+ createdAt: number;
372
+ updatedAt: number;
373
+ raw?: unknown;
374
+ }
375
+ /** Input to record a freshly-created (pending) payment. */
376
+ interface NewPayment {
377
+ id: string;
378
+ amount: number;
379
+ billableId?: string;
380
+ reference?: string;
381
+ raw?: unknown;
382
+ }
383
+ /**
384
+ * A ledger of payments keyed by the gateway payment id. Apps record a payment
385
+ * as `pending` on `createPayment` and let the webhook flip it to `paid`/`failed`
386
+ * — no more hand-rolling this per app. A memory implementation ships here; back
387
+ * it with your database in production (a `Payment` table keyed by `id`).
388
+ */
389
+ interface PaymentStore {
390
+ /** Insert a pending payment. Idempotent: a no-op if the id already exists. */
391
+ create(payment: NewPayment): Promise<void>;
392
+ /** Apply a terminal status; upserts if the payment wasn't recorded first. */
393
+ setStatus(id: string, status: PaymentRecordStatus, patch?: {
394
+ amount?: number;
395
+ raw?: unknown;
396
+ }): Promise<void>;
397
+ get(id: string): Promise<PaymentRecord | undefined>;
398
+ }
399
+ /** In-memory `PaymentStore` — per-process; swap for a durable one in production. */
400
+ declare class MemoryPaymentStore implements PaymentStore {
401
+ private readonly records;
402
+ create(payment: NewPayment): Promise<void>;
403
+ setStatus(id: string, status: PaymentRecordStatus, patch?: {
404
+ amount?: number;
405
+ raw?: unknown;
406
+ }): Promise<void>;
407
+ get(id: string): Promise<PaymentRecord | undefined>;
408
+ }
409
+ /** Result of applying a `PaymentEvent` to the ledger. */
410
+ interface PaymentApplyResult {
411
+ /** false = this event id was already processed (deduped) — you did nothing. */
412
+ fresh: boolean;
413
+ record?: PaymentRecord;
414
+ }
415
+ interface PaymentLedgerOptions {
416
+ /** Where payments are stored. Default: in-memory. */
417
+ store?: PaymentStore;
418
+ /**
419
+ * Webhook-id dedupe store. Default: in-memory. Share one durable store (e.g.
420
+ * Redis) across the app so a retried callback is applied exactly once.
421
+ */
422
+ webhooks?: WebhookStore;
423
+ }
424
+ /**
425
+ * Ties a `PaymentStore` to webhook idempotency so a retried callback is applied
426
+ * once. Record a payment on create, then feed every verified `PaymentEvent`
427
+ * through `apply` — it dedupes by `event.id` and updates the ledger.
428
+ *
429
+ * ```ts
430
+ * const ledger = new PaymentLedger()
431
+ * const inst = await gateway.createPayment(req)
432
+ * await ledger.created(inst, req) // pending
433
+ * // in the webhook route:
434
+ * const event = gateway.verifyWebhook(raw, sig)
435
+ * if (event) {
436
+ * const { fresh, record } = await ledger.apply(event)
437
+ * if (fresh && record?.status === 'paid') activate(record.billableId!)
438
+ * }
439
+ * ```
440
+ */
441
+ declare class PaymentLedger {
442
+ private readonly store;
443
+ private readonly webhooks;
444
+ constructor(options?: PaymentLedgerOptions);
445
+ /** Record a just-created payment as pending. Call after `createPayment`. */
446
+ created(instruction: PaymentInstruction, request: PaymentRequest): Promise<void>;
447
+ /**
448
+ * Apply a verified `PaymentEvent` idempotently. Dedupes by `event.id`; on a
449
+ * fresh event, flips the ledger record to paid/failed. If persisting fails the
450
+ * dedupe claim is released so the gateway's retry can reprocess.
451
+ *
452
+ * `onFresh` runs **inside the idempotency claim**, after the ledger is
453
+ * updated — use it for domain side effects (activate a subscription, mark a
454
+ * booking paid) that must apply exactly once with the payment. If it throws,
455
+ * the claim is released so the whole thing reprocesses on the gateway's retry.
456
+ */
457
+ apply(event: PaymentEvent, onFresh?: (record: PaymentRecord | undefined, event: PaymentEvent) => Promise<void> | void): Promise<PaymentApplyResult>;
458
+ get(id: string): Promise<PaymentRecord | undefined>;
459
+ }
460
+
461
+ /**
462
+ * Recurring billing for gateways with **no card-on-file** (ProxyPay, AppyPay,
463
+ * Multicaixa/EMIS): model a subscription as **one payment reference per period**.
464
+ * Each period you issue a fresh reference; when the webhook confirms it, the
465
+ * subscription's paid-through date is extended by one interval.
466
+ *
467
+ * Drive it with a scheduler: periodically call `due()` and `issueNext()` for the
468
+ * ones returned, and feed every verified `PaymentEvent` through `handleEvent()`.
469
+ */
470
+ type RecurringInterval = 'monthly' | 'yearly';
471
+ type RecurringStatus = 'pending' | 'active' | 'past_due' | 'canceled';
472
+ interface RecurringSubscription {
473
+ /** The customer/tenant this subscription bills. One per billableId. */
474
+ billableId: string;
475
+ plan: string;
476
+ /** Price per period, in the currency's minor unit (integer; cents). */
477
+ amount: number;
478
+ interval: RecurringInterval;
479
+ status: RecurringStatus;
480
+ /** Active until this instant (epoch ms). Undefined before the first payment. */
481
+ paidThrough?: number;
482
+ /** The outstanding (unpaid) payment id awaiting confirmation, if any. */
483
+ pendingPaymentId?: string;
484
+ /** Kept for gateways that need it each period (e.g. phone for Express push). */
485
+ customer?: PaymentRequest['customer'];
486
+ createdAt: number;
487
+ updatedAt: number;
488
+ }
489
+ interface RecurringStore {
490
+ save(sub: RecurringSubscription): Promise<void>;
491
+ get(billableId: string): Promise<RecurringSubscription | undefined>;
492
+ list(): Promise<RecurringSubscription[]>;
493
+ }
494
+ /** In-memory `RecurringStore` — swap for a durable one in production. */
495
+ declare class MemoryRecurringStore implements RecurringStore {
496
+ private readonly subs;
497
+ save(sub: RecurringSubscription): Promise<void>;
498
+ get(billableId: string): Promise<RecurringSubscription | undefined>;
499
+ list(): Promise<RecurringSubscription[]>;
500
+ }
501
+ /** Add one billing interval to an epoch-ms instant (calendar-aware). */
502
+ declare function addInterval(ms: number, interval: RecurringInterval): number;
503
+ interface RecurringBillingOptions {
504
+ /** Payment driver (ProxyPay / AppyPay / …). */
505
+ gateway: PaymentGateway;
506
+ /** Ledger for payment records + webhook idempotency. Default: in-memory. */
507
+ ledger?: PaymentLedger;
508
+ /** Where subscriptions live. Default: in-memory. */
509
+ store?: RecurringStore;
510
+ /** Days before period end that a subscription becomes `due()`. Default 5. */
511
+ leadDays?: number;
512
+ /** ISO 4217 currency passed to the gateway (defaults to the gateway's own). */
513
+ currency?: string;
514
+ }
515
+ interface SubscribeInput {
516
+ billableId: string;
517
+ plan: string;
518
+ amount: number;
519
+ interval: RecurringInterval;
520
+ customer?: PaymentRequest['customer'];
521
+ /** Extra fields passed to the gateway and echoed on the webhook. */
522
+ metadata?: Record<string, string>;
523
+ }
524
+ interface HandleEventResult {
525
+ /** false = the event was a duplicate (already applied) — nothing changed. */
526
+ applied: boolean;
527
+ subscription?: RecurringSubscription;
528
+ }
529
+ /**
530
+ * Coordinates reference-per-period recurring billing over a `PaymentGateway`.
531
+ * Stateful pieces are pluggable (`store`, `ledger`) so you can back them with a
532
+ * database; the defaults are in-memory.
533
+ */
534
+ declare class RecurringReferenceBilling {
535
+ private readonly gateway;
536
+ private readonly ledger;
537
+ private readonly store;
538
+ private readonly leadMs;
539
+ private readonly currency;
540
+ constructor(options: RecurringBillingOptions);
541
+ get(billableId: string): Promise<RecurringSubscription | undefined>;
542
+ /** Start a subscription and issue the first period's reference. */
543
+ subscribe(input: SubscribeInput): Promise<{
544
+ subscription: RecurringSubscription;
545
+ instruction: PaymentInstruction;
546
+ }>;
547
+ /**
548
+ * Issue the next period's reference for a subscription (call for the ones
549
+ * `due()` returns, or to re-collect after a lapse). Replaces any outstanding
550
+ * reference as the one now awaited.
551
+ */
552
+ issueNext(billableId: string, metadata?: Record<string, string>): Promise<PaymentInstruction>;
553
+ private issueFor;
554
+ /**
555
+ * Feed a verified `PaymentEvent`. Applies it once (via the ledger's
556
+ * idempotency): on success for the outstanding reference, extends
557
+ * `paidThrough` by one interval and marks the subscription active; on failure,
558
+ * marks it `past_due`.
559
+ */
560
+ handleEvent(event: PaymentEvent): Promise<HandleEventResult>;
561
+ /**
562
+ * Subscriptions that need their next reference issued now: not canceled,
563
+ * nothing outstanding, and within `leadDays` of the paid-through date (or
564
+ * never paid). Run this on a schedule and call `issueNext()` for each.
565
+ */
566
+ due(now?: number): Promise<RecurringSubscription[]>;
567
+ cancel(billableId: string): Promise<void>;
568
+ }
569
+
570
+ /**
571
+ * Money handling for the subscriptions ecosystem. **All amounts in the public
572
+ * API are integers in the currency's minor unit** — cents, `100 = 1.00`. This
573
+ * is the Stripe/Adyen convention: exact, no floating-point rounding, and no
574
+ * ambiguity about units. Use these helpers to convert at the human boundary
575
+ * (input forms, display) and to validate.
576
+ *
577
+ * ```ts
578
+ * toMinor(5000, 'AOA') // 500000 (5.000,00 Kz)
579
+ * toMajor(500000, 'AOA') // 5000
580
+ * formatMoney(500000, 'AOA', 'pt-AO') // "5.000,00 AOA"
581
+ * assertMinorUnits(2999) // ok ($29.99)
582
+ * assertMinorUnits(29.99) // throws
583
+ * ```
584
+ */
585
+ /** Number of minor-unit decimal places for a currency (default 2). */
586
+ declare function currencyDecimals(currency: string): number;
587
+ /** Convert a major-unit amount (e.g. `5000` Kz) to minor units (`500000`). */
588
+ declare function toMinor(major: number, currency: string): number;
589
+ /** Convert minor units (`500000`) back to a major-unit amount (`5000` Kz). */
590
+ declare function toMajor(minor: number, currency: string): number;
591
+ /** Format a minor-unit amount for display. Falls back to a plain string if Intl lacks the currency. */
592
+ declare function formatMoney(minor: number, currency: string, locale?: string): string;
593
+ /** True when `amount` is a valid minor-unit value (a non-negative integer). */
594
+ declare function isMinorUnits(amount: number): boolean;
595
+ /**
596
+ * Throw unless `amount` is a valid minor-unit value. Drivers call this so a
597
+ * major-unit slip (e.g. `29.99` instead of `2999`) fails fast instead of
598
+ * silently under/over-charging.
599
+ */
600
+ declare function assertMinorUnits(amount: number, label?: string): void;
352
601
 
353
602
  declare class StripeRequestError extends BasaltError {
354
603
  readonly httpStatus: number;
@@ -562,4 +811,4 @@ declare function billingRoutes(options: BillingRoutesOptions): BasaltRoute[];
562
811
  */
563
812
  declare function billingWebhookRoute(gateway: BillingGateway): BasaltRoute;
564
813
 
565
- export { type BillingGateway, type BillingPeriod, type BillingRoutesOptions, type CheckoutInput, type CreateSubscriptionInput, FakeBillingGateway, FakePaymentGateway, FeatureUnavailableError, type FeatureValue, GatewayUnsupportedError, MemorySubscriptionStore, MemoryUsageStore, MemoryWebhookStore, type Meter, NotSubscribedError, type PaymentEvent, type PaymentGateway, type PaymentInstruction, type PaymentRequest, type PlanDefinition, type Plans, type PortalInput, QuotaExceededError, type RedisLike, RedisUsageStore, type RedisUsageStoreOptions, type RedisWebhookClient, RedisWebhookStore, type RedisWebhookStoreOptions, SUBSCRIPTIONS, StripeBillingGateway, type StripeGatewayOptions, StripeRequestError, type SubscriptionRecord, type SubscriptionStatus, type SubscriptionStore, Subscriptions, type SubscriptionsOptions, type SubscriptionsPluginOptions, type SwapInput, UnknownPlanError, type UsageConsumeResult, type UsageStore, type WebhookEvent, WebhookInvalidError, type WebhookStore, billingRoutes, billingWebhookRoute, definePlans, featureLimit, isMeter, meter, planPrice, subscriptionsPlugin };
814
+ export { type BillingGateway, type BillingPeriod, type BillingRoutesOptions, type CheckoutInput, type CreateSubscriptionInput, FakeBillingGateway, FakePaymentGateway, FeatureUnavailableError, type FeatureValue, GatewayUnsupportedError, type HandleEventResult, MemoryPaymentStore, MemoryRecurringStore, MemorySubscriptionStore, MemoryUsageStore, MemoryWebhookStore, type Meter, type NewPayment, NotSubscribedError, type PaymentApplyResult, type PaymentEvent, type PaymentGateway, type PaymentInstruction, PaymentLedger, type PaymentLedgerOptions, type PaymentRecord, type PaymentRecordStatus, type PaymentRequest, type PaymentStore, type PlanDefinition, type Plans, type PortalInput, QuotaExceededError, type RecurringBillingOptions, type RecurringInterval, RecurringReferenceBilling, type RecurringStatus, type RecurringStore, type RecurringSubscription, type RedisLike, RedisUsageStore, type RedisUsageStoreOptions, type RedisWebhookClient, RedisWebhookStore, type RedisWebhookStoreOptions, SUBSCRIPTIONS, StripeBillingGateway, type StripeGatewayOptions, StripeRequestError, type SubscribeInput, type SubscriptionRecord, type SubscriptionStatus, type SubscriptionStore, Subscriptions, type SubscriptionsOptions, type SubscriptionsPluginOptions, type SwapInput, UnknownPlanError, type UsageConsumeResult, type UsageStore, type WebhookEvent, WebhookInvalidError, type WebhookStore, addInterval, assertMinorUnits, billingRoutes, billingWebhookRoute, currencyDecimals, definePlans, featureLimit, formatMoney, isMeter, isMinorUnits, meter, planPrice, subscriptionsPlugin, toMajor, toMinor };
package/dist/index.js CHANGED
@@ -208,6 +208,262 @@ var FakePaymentGateway = class {
208
208
  return JSON.parse(rawBody);
209
209
  }
210
210
  };
211
+ var MemoryPaymentStore = class {
212
+ records = /* @__PURE__ */ new Map();
213
+ async create(payment) {
214
+ if (this.records.has(payment.id)) return;
215
+ const now = Date.now();
216
+ this.records.set(payment.id, {
217
+ id: payment.id,
218
+ status: "pending",
219
+ amount: payment.amount,
220
+ ...payment.billableId ? { billableId: payment.billableId } : {},
221
+ ...payment.reference ? { reference: payment.reference } : {},
222
+ createdAt: now,
223
+ updatedAt: now,
224
+ ...payment.raw !== void 0 ? { raw: payment.raw } : {}
225
+ });
226
+ }
227
+ async setStatus(id, status, patch = {}) {
228
+ const now = Date.now();
229
+ const rec = this.records.get(id) ?? {
230
+ id,
231
+ status,
232
+ amount: patch.amount ?? 0,
233
+ createdAt: now,
234
+ updatedAt: now
235
+ };
236
+ rec.status = status;
237
+ rec.updatedAt = now;
238
+ if (patch.amount != null) rec.amount = patch.amount;
239
+ if (patch.raw !== void 0) rec.raw = patch.raw;
240
+ this.records.set(id, rec);
241
+ }
242
+ async get(id) {
243
+ return this.records.get(id);
244
+ }
245
+ };
246
+ var PaymentLedger = class {
247
+ store;
248
+ webhooks;
249
+ constructor(options = {}) {
250
+ this.store = options.store ?? new MemoryPaymentStore();
251
+ this.webhooks = options.webhooks ?? new MemoryWebhookStore();
252
+ }
253
+ /** Record a just-created payment as pending. Call after `createPayment`. */
254
+ async created(instruction, request) {
255
+ await this.store.create({
256
+ id: instruction.id,
257
+ amount: request.amount,
258
+ ...request.billableId ? { billableId: request.billableId } : {},
259
+ ...request.reference ? { reference: request.reference } : {},
260
+ ...instruction.raw !== void 0 ? { raw: instruction.raw } : {}
261
+ });
262
+ }
263
+ /**
264
+ * Apply a verified `PaymentEvent` idempotently. Dedupes by `event.id`; on a
265
+ * fresh event, flips the ledger record to paid/failed. If persisting fails the
266
+ * dedupe claim is released so the gateway's retry can reprocess.
267
+ *
268
+ * `onFresh` runs **inside the idempotency claim**, after the ledger is
269
+ * updated — use it for domain side effects (activate a subscription, mark a
270
+ * booking paid) that must apply exactly once with the payment. If it throws,
271
+ * the claim is released so the whole thing reprocesses on the gateway's retry.
272
+ */
273
+ async apply(event, onFresh) {
274
+ const fresh = await this.webhooks.markProcessed(event.id);
275
+ if (!fresh) return { fresh: false };
276
+ try {
277
+ const status = event.type === "payment.succeeded" ? "paid" : "failed";
278
+ await this.store.setStatus(event.paymentId, status, {
279
+ amount: event.amount,
280
+ ...event.raw !== void 0 ? { raw: event.raw } : {}
281
+ });
282
+ const record = await this.store.get(event.paymentId);
283
+ if (onFresh) await onFresh(record, event);
284
+ return { fresh: true, ...record ? { record } : {} };
285
+ } catch (error) {
286
+ await this.webhooks.release(event.id);
287
+ throw error;
288
+ }
289
+ }
290
+ get(id) {
291
+ return this.store.get(id);
292
+ }
293
+ };
294
+
295
+ // src/recurring.ts
296
+ var MemoryRecurringStore = class {
297
+ subs = /* @__PURE__ */ new Map();
298
+ async save(sub) {
299
+ this.subs.set(sub.billableId, sub);
300
+ }
301
+ async get(billableId) {
302
+ return this.subs.get(billableId);
303
+ }
304
+ async list() {
305
+ return [...this.subs.values()];
306
+ }
307
+ };
308
+ function addInterval(ms, interval) {
309
+ const d = new Date(ms);
310
+ if (interval === "yearly") d.setFullYear(d.getFullYear() + 1);
311
+ else d.setMonth(d.getMonth() + 1);
312
+ return d.getTime();
313
+ }
314
+ var RecurringReferenceBilling = class {
315
+ gateway;
316
+ ledger;
317
+ store;
318
+ leadMs;
319
+ currency;
320
+ constructor(options) {
321
+ this.gateway = options.gateway;
322
+ this.ledger = options.ledger ?? new PaymentLedger();
323
+ this.store = options.store ?? new MemoryRecurringStore();
324
+ this.leadMs = (options.leadDays ?? 5) * 864e5;
325
+ this.currency = options.currency;
326
+ }
327
+ get(billableId) {
328
+ return this.store.get(billableId);
329
+ }
330
+ /** Start a subscription and issue the first period's reference. */
331
+ async subscribe(input) {
332
+ const now = Date.now();
333
+ const subscription = {
334
+ billableId: input.billableId,
335
+ plan: input.plan,
336
+ amount: input.amount,
337
+ interval: input.interval,
338
+ status: "pending",
339
+ ...input.customer ? { customer: input.customer } : {},
340
+ createdAt: now,
341
+ updatedAt: now
342
+ };
343
+ const instruction = await this.issueFor(subscription, input.metadata);
344
+ return { subscription, instruction };
345
+ }
346
+ /**
347
+ * Issue the next period's reference for a subscription (call for the ones
348
+ * `due()` returns, or to re-collect after a lapse). Replaces any outstanding
349
+ * reference as the one now awaited.
350
+ */
351
+ async issueNext(billableId, metadata) {
352
+ const sub = await this.store.get(billableId);
353
+ if (!sub) throw new Error(`no recurring subscription for ${billableId}`);
354
+ if (sub.status === "canceled") throw new Error(`subscription ${billableId} is canceled`);
355
+ return this.issueFor(sub, metadata);
356
+ }
357
+ async issueFor(sub, metadata) {
358
+ const reference = `${sub.billableId}:${sub.plan}:${Date.now()}`;
359
+ const request = {
360
+ billableId: sub.billableId,
361
+ amount: sub.amount,
362
+ reference,
363
+ ...this.currency ? { currency: this.currency } : {},
364
+ ...sub.customer ? { customer: sub.customer } : {},
365
+ metadata: { recurring: "1", plan: sub.plan, interval: sub.interval, ...metadata }
366
+ };
367
+ const instruction = await this.gateway.createPayment(request);
368
+ await this.ledger.created(instruction, request);
369
+ sub.pendingPaymentId = instruction.id;
370
+ sub.updatedAt = Date.now();
371
+ await this.store.save(sub);
372
+ return instruction;
373
+ }
374
+ /**
375
+ * Feed a verified `PaymentEvent`. Applies it once (via the ledger's
376
+ * idempotency): on success for the outstanding reference, extends
377
+ * `paidThrough` by one interval and marks the subscription active; on failure,
378
+ * marks it `past_due`.
379
+ */
380
+ async handleEvent(event) {
381
+ let subscription;
382
+ const { fresh } = await this.ledger.apply(event, async (record) => {
383
+ const billableId = event.billableId ?? record?.billableId;
384
+ if (!billableId) return;
385
+ const sub = await this.store.get(billableId);
386
+ if (!sub || event.paymentId !== sub.pendingPaymentId) return;
387
+ if (event.type === "payment.succeeded") {
388
+ const base = sub.paidThrough && sub.paidThrough > Date.now() ? sub.paidThrough : Date.now();
389
+ sub.paidThrough = addInterval(base, sub.interval);
390
+ sub.status = "active";
391
+ delete sub.pendingPaymentId;
392
+ } else {
393
+ sub.status = "past_due";
394
+ }
395
+ sub.updatedAt = Date.now();
396
+ await this.store.save(sub);
397
+ subscription = sub;
398
+ });
399
+ return { applied: fresh, ...subscription ? { subscription } : {} };
400
+ }
401
+ /**
402
+ * Subscriptions that need their next reference issued now: not canceled,
403
+ * nothing outstanding, and within `leadDays` of the paid-through date (or
404
+ * never paid). Run this on a schedule and call `issueNext()` for each.
405
+ */
406
+ async due(now = Date.now()) {
407
+ const subs = await this.store.list();
408
+ return subs.filter(
409
+ (s) => s.status !== "canceled" && !s.pendingPaymentId && (s.paidThrough == null || s.paidThrough - now <= this.leadMs)
410
+ );
411
+ }
412
+ async cancel(billableId) {
413
+ const sub = await this.store.get(billableId);
414
+ if (!sub) return;
415
+ sub.status = "canceled";
416
+ delete sub.pendingPaymentId;
417
+ sub.updatedAt = Date.now();
418
+ await this.store.save(sub);
419
+ }
420
+ };
421
+
422
+ // src/money.ts
423
+ var CURRENCY_DECIMALS = {
424
+ AOA: 2,
425
+ USD: 2,
426
+ EUR: 2,
427
+ GBP: 2,
428
+ BRL: 2,
429
+ ZAR: 2,
430
+ MZN: 2,
431
+ CVE: 2,
432
+ NGN: 2,
433
+ KES: 2,
434
+ JPY: 0,
435
+ XOF: 0,
436
+ XAF: 0,
437
+ CLP: 0
438
+ };
439
+ var DEFAULT_DECIMALS = 2;
440
+ function currencyDecimals(currency) {
441
+ return CURRENCY_DECIMALS[currency.toUpperCase()] ?? DEFAULT_DECIMALS;
442
+ }
443
+ function toMinor(major, currency) {
444
+ return Math.round(major * 10 ** currencyDecimals(currency));
445
+ }
446
+ function toMajor(minor, currency) {
447
+ return minor / 10 ** currencyDecimals(currency);
448
+ }
449
+ function formatMoney(minor, currency, locale = "en-US") {
450
+ const major = toMajor(minor, currency);
451
+ try {
452
+ return new Intl.NumberFormat(locale, { style: "currency", currency }).format(major);
453
+ } catch {
454
+ return `${major.toFixed(currencyDecimals(currency))} ${currency.toUpperCase()}`;
455
+ }
456
+ }
457
+ function isMinorUnits(amount) {
458
+ return Number.isInteger(amount) && amount >= 0;
459
+ }
460
+ function assertMinorUnits(amount, label = "amount") {
461
+ if (!isMinorUnits(amount)) {
462
+ throw new RangeError(
463
+ `${label} must be a non-negative integer in minor units (e.g. cents), got ${amount}`
464
+ );
465
+ }
466
+ }
211
467
 
212
468
  // src/drivers/stripe.ts
213
469
  import { createHmac, timingSafeEqual } from "crypto";
@@ -711,11 +967,15 @@ export {
711
967
  FakePaymentGateway,
712
968
  FeatureUnavailableError,
713
969
  GatewayUnsupportedError,
970
+ MemoryPaymentStore,
971
+ MemoryRecurringStore,
714
972
  MemorySubscriptionStore,
715
973
  MemoryUsageStore,
716
974
  MemoryWebhookStore,
717
975
  NotSubscribedError,
976
+ PaymentLedger,
718
977
  QuotaExceededError,
978
+ RecurringReferenceBilling,
719
979
  RedisUsageStore,
720
980
  RedisWebhookStore,
721
981
  SUBSCRIPTIONS,
@@ -724,12 +984,19 @@ export {
724
984
  Subscriptions,
725
985
  UnknownPlanError,
726
986
  WebhookInvalidError,
987
+ addInterval,
988
+ assertMinorUnits,
727
989
  billingRoutes,
728
990
  billingWebhookRoute,
991
+ currencyDecimals,
729
992
  definePlans,
730
993
  featureLimit,
994
+ formatMoney,
731
995
  isMeter,
996
+ isMinorUnits,
732
997
  meter,
733
998
  planPrice,
734
- subscriptionsPlugin
999
+ subscriptionsPlugin,
1000
+ toMajor,
1001
+ toMinor
735
1002
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/subscriptions",
3
- "version": "1.0.1",
3
+ "version": "2.0.0",
4
4
  "description": "Billing for Basalt, Cashier/Soulbscription-style: declarative plans, subscriptions with trials, feature flags, usage limits, gateway drivers and idempotent webhooks.",
5
5
  "license": "MIT",
6
6
  "type": "module",