@basaltkit/subscriptions 2.0.0 → 2.2.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
@@ -161,6 +161,24 @@ declare class WebhookInvalidError extends BasaltError {
161
161
  readonly status = 400;
162
162
  constructor();
163
163
  }
164
+ /**
165
+ * Thrown when a gateway is asked to verify a webhook but no signing secret is
166
+ * configured. Verification fails closed: an unauthenticated callback must never
167
+ * be trusted (anyone could forge a `payment.succeeded`).
168
+ */
169
+ declare class WebhookSecretMissingError extends BasaltError {
170
+ readonly status = 500;
171
+ constructor(gateway: string);
172
+ }
173
+ /**
174
+ * Thrown when a confirmed payment's amount does not match the amount that was
175
+ * originally requested for that payment id — an underpayment, or a forged /
176
+ * mis-routed callback trying to settle an invoice for less.
177
+ */
178
+ declare class PaymentAmountMismatchError extends BasaltError {
179
+ readonly status = 400;
180
+ constructor(paymentId: string, expected: number, actual: number);
181
+ }
164
182
  /** Gateway-agnostic webhook event, already translated to domain terms. */
165
183
  interface WebhookEvent {
166
184
  /** Unique id at the gateway — used for idempotent processing. */
@@ -412,6 +430,26 @@ interface PaymentApplyResult {
412
430
  fresh: boolean;
413
431
  record?: PaymentRecord;
414
432
  }
433
+ /** Lifecycle events emitted by the ledger — subscribe with `ledger.on(...)`. */
434
+ interface PaymentLedgerEvents {
435
+ /** A payment was recorded as pending (on `created`). */
436
+ recorded: {
437
+ record: PaymentRecord | undefined;
438
+ payment: NewPayment;
439
+ };
440
+ /** A payment was confirmed paid (fresh `apply` of a `payment.succeeded`). */
441
+ confirmed: {
442
+ record: PaymentRecord | undefined;
443
+ event: PaymentEvent;
444
+ };
445
+ /** A payment failed (fresh `apply` of a `payment.failed`). */
446
+ failed: {
447
+ record: PaymentRecord | undefined;
448
+ event: PaymentEvent;
449
+ };
450
+ }
451
+ type PaymentLedgerEvent = keyof PaymentLedgerEvents;
452
+ type PaymentLedgerListener<K extends PaymentLedgerEvent> = (payload: PaymentLedgerEvents[K]) => void | Promise<void>;
415
453
  interface PaymentLedgerOptions {
416
454
  /** Where payments are stored. Default: in-memory. */
417
455
  store?: PaymentStore;
@@ -420,6 +458,12 @@ interface PaymentLedgerOptions {
420
458
  * Redis) across the app so a retried callback is applied exactly once.
421
459
  */
422
460
  webhooks?: WebhookStore;
461
+ /**
462
+ * Called when a lifecycle listener throws. Listeners are best-effort side
463
+ * effects (notifications, analytics) that never roll back a payment — a
464
+ * throwing one is reported here instead. Default: swallow.
465
+ */
466
+ onListenerError?: (error: unknown, event: PaymentLedgerEvent) => void;
423
467
  }
424
468
  /**
425
469
  * Ties a `PaymentStore` to webhook idempotency so a retried callback is applied
@@ -441,7 +485,17 @@ interface PaymentLedgerOptions {
441
485
  declare class PaymentLedger {
442
486
  private readonly store;
443
487
  private readonly webhooks;
488
+ private readonly onListenerError;
489
+ private readonly listeners;
444
490
  constructor(options?: PaymentLedgerOptions);
491
+ /**
492
+ * Subscribe to a lifecycle event (`recorded`/`confirmed`/`failed`). Listeners
493
+ * are best-effort: they run after the payment is safely persisted and a
494
+ * throwing one never rolls it back (it's reported via `onListenerError`).
495
+ * Returns an unsubscribe function.
496
+ */
497
+ on<K extends PaymentLedgerEvent>(event: K, listener: PaymentLedgerListener<K>): () => void;
498
+ private emit;
445
499
  /** Record a just-created payment as pending. Call after `createPayment`. */
446
500
  created(instruction: PaymentInstruction, request: PaymentRequest): Promise<void>;
447
501
  /**
@@ -811,4 +865,4 @@ declare function billingRoutes(options: BillingRoutesOptions): BasaltRoute[];
811
865
  */
812
866
  declare function billingWebhookRoute(gateway: BillingGateway): BasaltRoute;
813
867
 
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 };
868
+ 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, PaymentAmountMismatchError, 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, WebhookSecretMissingError, type WebhookStore, addInterval, assertMinorUnits, billingRoutes, billingWebhookRoute, currencyDecimals, definePlans, featureLimit, formatMoney, isMeter, isMinorUnits, meter, planPrice, subscriptionsPlugin, toMajor, toMinor };
package/dist/index.js CHANGED
@@ -160,6 +160,24 @@ var WebhookInvalidError = class extends BasaltError2 {
160
160
  super("BILLING_WEBHOOK_INVALID", "Webhook signature verification failed.");
161
161
  }
162
162
  };
163
+ var WebhookSecretMissingError = class extends BasaltError2 {
164
+ status = 500;
165
+ constructor(gateway) {
166
+ super(
167
+ "BILLING_WEBHOOK_SECRET_MISSING",
168
+ `${gateway}: cannot verify a webhook without a configured signing secret \u2014 refusing to trust an unsigned callback.`
169
+ );
170
+ }
171
+ };
172
+ var PaymentAmountMismatchError = class extends BasaltError2 {
173
+ status = 400;
174
+ constructor(paymentId, expected, actual) {
175
+ super(
176
+ "BILLING_PAYMENT_AMOUNT_MISMATCH",
177
+ `Payment ${paymentId} was requested for ${expected} but the webhook confirmed ${actual} \u2014 refusing to mark it paid.`
178
+ );
179
+ }
180
+ };
163
181
  var FakeBillingGateway = class {
164
182
  name = "fake";
165
183
  created = [];
@@ -246,19 +264,46 @@ var MemoryPaymentStore = class {
246
264
  var PaymentLedger = class {
247
265
  store;
248
266
  webhooks;
267
+ onListenerError;
268
+ listeners = { recorded: /* @__PURE__ */ new Set(), confirmed: /* @__PURE__ */ new Set(), failed: /* @__PURE__ */ new Set() };
249
269
  constructor(options = {}) {
250
270
  this.store = options.store ?? new MemoryPaymentStore();
251
271
  this.webhooks = options.webhooks ?? new MemoryWebhookStore();
272
+ this.onListenerError = options.onListenerError ?? (() => {
273
+ });
274
+ }
275
+ /**
276
+ * Subscribe to a lifecycle event (`recorded`/`confirmed`/`failed`). Listeners
277
+ * are best-effort: they run after the payment is safely persisted and a
278
+ * throwing one never rolls it back (it's reported via `onListenerError`).
279
+ * Returns an unsubscribe function.
280
+ */
281
+ on(event, listener) {
282
+ this.listeners[event].add(listener);
283
+ return () => {
284
+ this.listeners[event].delete(listener);
285
+ };
286
+ }
287
+ async emit(event, payload) {
288
+ for (const listener of this.listeners[event]) {
289
+ try {
290
+ await listener(payload);
291
+ } catch (error) {
292
+ this.onListenerError(error, event);
293
+ }
294
+ }
252
295
  }
253
296
  /** Record a just-created payment as pending. Call after `createPayment`. */
254
297
  async created(instruction, request) {
255
- await this.store.create({
298
+ const payment = {
256
299
  id: instruction.id,
257
300
  amount: request.amount,
258
301
  ...request.billableId ? { billableId: request.billableId } : {},
259
302
  ...request.reference ? { reference: request.reference } : {},
260
303
  ...instruction.raw !== void 0 ? { raw: instruction.raw } : {}
261
- });
304
+ };
305
+ await this.store.create(payment);
306
+ await this.emit("recorded", { record: await this.store.get(instruction.id), payment });
262
307
  }
263
308
  /**
264
309
  * Apply a verified `PaymentEvent` idempotently. Dedupes by `event.id`; on a
@@ -273,19 +318,27 @@ var PaymentLedger = class {
273
318
  async apply(event, onFresh) {
274
319
  const fresh = await this.webhooks.markProcessed(event.id);
275
320
  if (!fresh) return { fresh: false };
321
+ let record;
276
322
  try {
277
323
  const status = event.type === "payment.succeeded" ? "paid" : "failed";
324
+ if (status === "paid") {
325
+ const existing = await this.store.get(event.paymentId);
326
+ if (existing && existing.amount > 0 && event.amount !== existing.amount) {
327
+ throw new PaymentAmountMismatchError(event.paymentId, existing.amount, event.amount);
328
+ }
329
+ }
278
330
  await this.store.setStatus(event.paymentId, status, {
279
331
  amount: event.amount,
280
332
  ...event.raw !== void 0 ? { raw: event.raw } : {}
281
333
  });
282
- const record = await this.store.get(event.paymentId);
334
+ record = await this.store.get(event.paymentId);
283
335
  if (onFresh) await onFresh(record, event);
284
- return { fresh: true, ...record ? { record } : {} };
285
336
  } catch (error) {
286
337
  await this.webhooks.release(event.id);
287
338
  throw error;
288
339
  }
340
+ await this.emit(event.type === "payment.succeeded" ? "confirmed" : "failed", { record, event });
341
+ return { fresh: true, ...record ? { record } : {} };
289
342
  }
290
343
  get(id) {
291
344
  return this.store.get(id);
@@ -973,6 +1026,7 @@ export {
973
1026
  MemoryUsageStore,
974
1027
  MemoryWebhookStore,
975
1028
  NotSubscribedError,
1029
+ PaymentAmountMismatchError,
976
1030
  PaymentLedger,
977
1031
  QuotaExceededError,
978
1032
  RecurringReferenceBilling,
@@ -984,6 +1038,7 @@ export {
984
1038
  Subscriptions,
985
1039
  UnknownPlanError,
986
1040
  WebhookInvalidError,
1041
+ WebhookSecretMissingError,
987
1042
  addInterval,
988
1043
  assertMinorUnits,
989
1044
  billingRoutes,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/subscriptions",
3
- "version": "2.0.0",
3
+ "version": "2.2.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",
@@ -15,7 +15,7 @@
15
15
  ],
16
16
  "dependencies": {
17
17
  "@basaltkit/core": "^1.0.0",
18
- "@basaltkit/fastify": "^1.0.0"
18
+ "@basaltkit/fastify": "^1.1.0"
19
19
  },
20
20
  "peerDependencies": {
21
21
  "zod": "^3.24.0 || ^4.0.0"