@basaltkit/subscriptions 1.2.0 → 2.1.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;
@@ -403,6 +412,26 @@ interface PaymentApplyResult {
403
412
  fresh: boolean;
404
413
  record?: PaymentRecord;
405
414
  }
415
+ /** Lifecycle events emitted by the ledger — subscribe with `ledger.on(...)`. */
416
+ interface PaymentLedgerEvents {
417
+ /** A payment was recorded as pending (on `created`). */
418
+ recorded: {
419
+ record: PaymentRecord | undefined;
420
+ payment: NewPayment;
421
+ };
422
+ /** A payment was confirmed paid (fresh `apply` of a `payment.succeeded`). */
423
+ confirmed: {
424
+ record: PaymentRecord | undefined;
425
+ event: PaymentEvent;
426
+ };
427
+ /** A payment failed (fresh `apply` of a `payment.failed`). */
428
+ failed: {
429
+ record: PaymentRecord | undefined;
430
+ event: PaymentEvent;
431
+ };
432
+ }
433
+ type PaymentLedgerEvent = keyof PaymentLedgerEvents;
434
+ type PaymentLedgerListener<K extends PaymentLedgerEvent> = (payload: PaymentLedgerEvents[K]) => void | Promise<void>;
406
435
  interface PaymentLedgerOptions {
407
436
  /** Where payments are stored. Default: in-memory. */
408
437
  store?: PaymentStore;
@@ -411,6 +440,12 @@ interface PaymentLedgerOptions {
411
440
  * Redis) across the app so a retried callback is applied exactly once.
412
441
  */
413
442
  webhooks?: WebhookStore;
443
+ /**
444
+ * Called when a lifecycle listener throws. Listeners are best-effort side
445
+ * effects (notifications, analytics) that never roll back a payment — a
446
+ * throwing one is reported here instead. Default: swallow.
447
+ */
448
+ onListenerError?: (error: unknown, event: PaymentLedgerEvent) => void;
414
449
  }
415
450
  /**
416
451
  * Ties a `PaymentStore` to webhook idempotency so a retried callback is applied
@@ -432,7 +467,17 @@ interface PaymentLedgerOptions {
432
467
  declare class PaymentLedger {
433
468
  private readonly store;
434
469
  private readonly webhooks;
470
+ private readonly onListenerError;
471
+ private readonly listeners;
435
472
  constructor(options?: PaymentLedgerOptions);
473
+ /**
474
+ * Subscribe to a lifecycle event (`recorded`/`confirmed`/`failed`). Listeners
475
+ * are best-effort: they run after the payment is safely persisted and a
476
+ * throwing one never rolls it back (it's reported via `onListenerError`).
477
+ * Returns an unsubscribe function.
478
+ */
479
+ on<K extends PaymentLedgerEvent>(event: K, listener: PaymentLedgerListener<K>): () => void;
480
+ private emit;
436
481
  /** Record a just-created payment as pending. Call after `createPayment`. */
437
482
  created(instruction: PaymentInstruction, request: PaymentRequest): Promise<void>;
438
483
  /**
@@ -464,7 +509,7 @@ interface RecurringSubscription {
464
509
  /** The customer/tenant this subscription bills. One per billableId. */
465
510
  billableId: string;
466
511
  plan: string;
467
- /** Price per period, in the currency's major unit. */
512
+ /** Price per period, in the currency's minor unit (integer; cents). */
468
513
  amount: number;
469
514
  interval: RecurringInterval;
470
515
  status: RecurringStatus;
@@ -558,6 +603,38 @@ declare class RecurringReferenceBilling {
558
603
  cancel(billableId: string): Promise<void>;
559
604
  }
560
605
 
606
+ /**
607
+ * Money handling for the subscriptions ecosystem. **All amounts in the public
608
+ * API are integers in the currency's minor unit** — cents, `100 = 1.00`. This
609
+ * is the Stripe/Adyen convention: exact, no floating-point rounding, and no
610
+ * ambiguity about units. Use these helpers to convert at the human boundary
611
+ * (input forms, display) and to validate.
612
+ *
613
+ * ```ts
614
+ * toMinor(5000, 'AOA') // 500000 (5.000,00 Kz)
615
+ * toMajor(500000, 'AOA') // 5000
616
+ * formatMoney(500000, 'AOA', 'pt-AO') // "5.000,00 AOA"
617
+ * assertMinorUnits(2999) // ok ($29.99)
618
+ * assertMinorUnits(29.99) // throws
619
+ * ```
620
+ */
621
+ /** Number of minor-unit decimal places for a currency (default 2). */
622
+ declare function currencyDecimals(currency: string): number;
623
+ /** Convert a major-unit amount (e.g. `5000` Kz) to minor units (`500000`). */
624
+ declare function toMinor(major: number, currency: string): number;
625
+ /** Convert minor units (`500000`) back to a major-unit amount (`5000` Kz). */
626
+ declare function toMajor(minor: number, currency: string): number;
627
+ /** Format a minor-unit amount for display. Falls back to a plain string if Intl lacks the currency. */
628
+ declare function formatMoney(minor: number, currency: string, locale?: string): string;
629
+ /** True when `amount` is a valid minor-unit value (a non-negative integer). */
630
+ declare function isMinorUnits(amount: number): boolean;
631
+ /**
632
+ * Throw unless `amount` is a valid minor-unit value. Drivers call this so a
633
+ * major-unit slip (e.g. `29.99` instead of `2999`) fails fast instead of
634
+ * silently under/over-charging.
635
+ */
636
+ declare function assertMinorUnits(amount: number, label?: string): void;
637
+
561
638
  declare class StripeRequestError extends BasaltError {
562
639
  readonly httpStatus: number;
563
640
  constructor(httpStatus: number, message: string);
@@ -770,4 +847,4 @@ declare function billingRoutes(options: BillingRoutesOptions): BasaltRoute[];
770
847
  */
771
848
  declare function billingWebhookRoute(gateway: BillingGateway): BasaltRoute;
772
849
 
773
- 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, billingRoutes, billingWebhookRoute, definePlans, featureLimit, isMeter, meter, planPrice, subscriptionsPlugin };
850
+ 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 PaymentLedgerEvent, type PaymentLedgerEvents, type PaymentLedgerListener, 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
@@ -246,19 +246,46 @@ var MemoryPaymentStore = class {
246
246
  var PaymentLedger = class {
247
247
  store;
248
248
  webhooks;
249
+ onListenerError;
250
+ listeners = { recorded: /* @__PURE__ */ new Set(), confirmed: /* @__PURE__ */ new Set(), failed: /* @__PURE__ */ new Set() };
249
251
  constructor(options = {}) {
250
252
  this.store = options.store ?? new MemoryPaymentStore();
251
253
  this.webhooks = options.webhooks ?? new MemoryWebhookStore();
254
+ this.onListenerError = options.onListenerError ?? (() => {
255
+ });
256
+ }
257
+ /**
258
+ * Subscribe to a lifecycle event (`recorded`/`confirmed`/`failed`). Listeners
259
+ * are best-effort: they run after the payment is safely persisted and a
260
+ * throwing one never rolls it back (it's reported via `onListenerError`).
261
+ * Returns an unsubscribe function.
262
+ */
263
+ on(event, listener) {
264
+ this.listeners[event].add(listener);
265
+ return () => {
266
+ this.listeners[event].delete(listener);
267
+ };
268
+ }
269
+ async emit(event, payload) {
270
+ for (const listener of this.listeners[event]) {
271
+ try {
272
+ await listener(payload);
273
+ } catch (error) {
274
+ this.onListenerError(error, event);
275
+ }
276
+ }
252
277
  }
253
278
  /** Record a just-created payment as pending. Call after `createPayment`. */
254
279
  async created(instruction, request) {
255
- await this.store.create({
280
+ const payment = {
256
281
  id: instruction.id,
257
282
  amount: request.amount,
258
283
  ...request.billableId ? { billableId: request.billableId } : {},
259
284
  ...request.reference ? { reference: request.reference } : {},
260
285
  ...instruction.raw !== void 0 ? { raw: instruction.raw } : {}
261
- });
286
+ };
287
+ await this.store.create(payment);
288
+ await this.emit("recorded", { record: await this.store.get(instruction.id), payment });
262
289
  }
263
290
  /**
264
291
  * Apply a verified `PaymentEvent` idempotently. Dedupes by `event.id`; on a
@@ -273,19 +300,21 @@ var PaymentLedger = class {
273
300
  async apply(event, onFresh) {
274
301
  const fresh = await this.webhooks.markProcessed(event.id);
275
302
  if (!fresh) return { fresh: false };
303
+ let record;
276
304
  try {
277
305
  const status = event.type === "payment.succeeded" ? "paid" : "failed";
278
306
  await this.store.setStatus(event.paymentId, status, {
279
307
  amount: event.amount,
280
308
  ...event.raw !== void 0 ? { raw: event.raw } : {}
281
309
  });
282
- const record = await this.store.get(event.paymentId);
310
+ record = await this.store.get(event.paymentId);
283
311
  if (onFresh) await onFresh(record, event);
284
- return { fresh: true, ...record ? { record } : {} };
285
312
  } catch (error) {
286
313
  await this.webhooks.release(event.id);
287
314
  throw error;
288
315
  }
316
+ await this.emit(event.type === "payment.succeeded" ? "confirmed" : "failed", { record, event });
317
+ return { fresh: true, ...record ? { record } : {} };
289
318
  }
290
319
  get(id) {
291
320
  return this.store.get(id);
@@ -419,6 +448,52 @@ var RecurringReferenceBilling = class {
419
448
  }
420
449
  };
421
450
 
451
+ // src/money.ts
452
+ var CURRENCY_DECIMALS = {
453
+ AOA: 2,
454
+ USD: 2,
455
+ EUR: 2,
456
+ GBP: 2,
457
+ BRL: 2,
458
+ ZAR: 2,
459
+ MZN: 2,
460
+ CVE: 2,
461
+ NGN: 2,
462
+ KES: 2,
463
+ JPY: 0,
464
+ XOF: 0,
465
+ XAF: 0,
466
+ CLP: 0
467
+ };
468
+ var DEFAULT_DECIMALS = 2;
469
+ function currencyDecimals(currency) {
470
+ return CURRENCY_DECIMALS[currency.toUpperCase()] ?? DEFAULT_DECIMALS;
471
+ }
472
+ function toMinor(major, currency) {
473
+ return Math.round(major * 10 ** currencyDecimals(currency));
474
+ }
475
+ function toMajor(minor, currency) {
476
+ return minor / 10 ** currencyDecimals(currency);
477
+ }
478
+ function formatMoney(minor, currency, locale = "en-US") {
479
+ const major = toMajor(minor, currency);
480
+ try {
481
+ return new Intl.NumberFormat(locale, { style: "currency", currency }).format(major);
482
+ } catch {
483
+ return `${major.toFixed(currencyDecimals(currency))} ${currency.toUpperCase()}`;
484
+ }
485
+ }
486
+ function isMinorUnits(amount) {
487
+ return Number.isInteger(amount) && amount >= 0;
488
+ }
489
+ function assertMinorUnits(amount, label = "amount") {
490
+ if (!isMinorUnits(amount)) {
491
+ throw new RangeError(
492
+ `${label} must be a non-negative integer in minor units (e.g. cents), got ${amount}`
493
+ );
494
+ }
495
+ }
496
+
422
497
  // src/drivers/stripe.ts
423
498
  import { createHmac, timingSafeEqual } from "crypto";
424
499
  import { BasaltError as BasaltError3 } from "@basaltkit/core";
@@ -939,12 +1014,18 @@ export {
939
1014
  UnknownPlanError,
940
1015
  WebhookInvalidError,
941
1016
  addInterval,
1017
+ assertMinorUnits,
942
1018
  billingRoutes,
943
1019
  billingWebhookRoute,
1020
+ currencyDecimals,
944
1021
  definePlans,
945
1022
  featureLimit,
1023
+ formatMoney,
946
1024
  isMeter,
1025
+ isMinorUnits,
947
1026
  meter,
948
1027
  planPrice,
949
- subscriptionsPlugin
1028
+ subscriptionsPlugin,
1029
+ toMajor,
1030
+ toMinor
950
1031
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/subscriptions",
3
- "version": "1.2.0",
3
+ "version": "2.1.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",
@@ -14,8 +14,8 @@
14
14
  "dist"
15
15
  ],
16
16
  "dependencies": {
17
- "@basaltkit/core": "^1.0.0",
18
- "@basaltkit/fastify": "^1.0.0"
17
+ "@basaltkit/fastify": "^1.0.0",
18
+ "@basaltkit/core": "^1.0.0"
19
19
  },
20
20
  "peerDependencies": {
21
21
  "zod": "^3.24.0 || ^4.0.0"