@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 +55 -1
- package/dist/index.js +59 -4
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
18
|
+
"@basaltkit/fastify": "^1.1.0"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
21
|
"zod": "^3.24.0 || ^4.0.0"
|