@basaltkit/subscriptions 2.1.0 → 2.3.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 +34 -1
- package/dist/index.d.ts +140 -1
- package/dist/index.js +278 -5
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @basaltkit/subscriptions
|
|
2
2
|
|
|
3
|
-
Billing for the Basalt framework, in the style of Laravel Cashier/Soulbscription: declarative plans, subscriptions with a trial period, features with usage limits, Stripe integration, and idempotent webhooks. You need this module when your SaaS application charges monthly fees and limits features by plan.
|
|
3
|
+
Billing for the Basalt framework, in the style of Laravel Cashier/Soulbscription: declarative plans, subscriptions with a trial period, features with usage limits, Stripe / Paddle / Lemon Squeezy integration, and idempotent webhooks. You need this module when your SaaS application charges monthly fees and limits features by plan.
|
|
4
4
|
|
|
5
5
|
## What this module solves
|
|
6
6
|
|
|
@@ -168,6 +168,39 @@ const gateway = new StripeBillingGateway({
|
|
|
168
168
|
export const subscriptions = new Subscriptions({ plans, gateway, fallbackPlan: 'free' })
|
|
169
169
|
```
|
|
170
170
|
|
|
171
|
+
### Paddle gateway
|
|
172
|
+
|
|
173
|
+
`PaddleBillingGateway` targets **Paddle Billing** the same way (no SDK, injectable `fetch`), mapping plans to Paddle *Price IDs* (`pri_…`) and billables to *Customer IDs* (`ctm_…`). Paddle is checkout-first, so `createSubscription`/`createCheckoutSession` create a transaction and the durable subscription ref arrives on a `subscription.*` webhook.
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
import { PaddleBillingGateway, Subscriptions } from '@basaltkit/subscriptions'
|
|
177
|
+
|
|
178
|
+
const gateway = new PaddleBillingGateway({
|
|
179
|
+
apiKey: process.env.PADDLE_API_KEY!,
|
|
180
|
+
webhookSecret: process.env.PADDLE_NOTIFICATION_SECRET!, // ntfset_...
|
|
181
|
+
priceId: (plan, period) => ({ pro: { monthly: 'pri_pro_m', yearly: 'pri_pro_y' } })[plan]![period],
|
|
182
|
+
customerId: async (billableId) => getOrCreatePaddleCustomer(billableId),
|
|
183
|
+
})
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Webhook signatures use Paddle's `Paddle-Signature` scheme (`ts=…;h1=…`, HMAC-SHA256 over `${ts}:${rawBody}`) — verified by the driver, with the same 5-minute timestamp tolerance as Stripe.
|
|
187
|
+
|
|
188
|
+
### Lemon Squeezy gateway
|
|
189
|
+
|
|
190
|
+
`LemonSqueezyBillingGateway` targets the Lemon Squeezy REST API (JSON:API, no SDK), mapping plans to *Variant IDs* and using your *Store ID* for checkouts. Also checkout-first; webhook signatures use the `X-Signature` header (a bare HMAC-SHA256 hex of the raw body — no timestamp).
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
import { LemonSqueezyBillingGateway, Subscriptions } from '@basaltkit/subscriptions'
|
|
194
|
+
|
|
195
|
+
const gateway = new LemonSqueezyBillingGateway({
|
|
196
|
+
apiKey: process.env.LEMONSQUEEZY_API_KEY!,
|
|
197
|
+
webhookSecret: process.env.LEMONSQUEEZY_WEBHOOK_SECRET!,
|
|
198
|
+
storeId: process.env.LEMONSQUEEZY_STORE_ID!,
|
|
199
|
+
variantId: (plan, period) => ({ pro: { monthly: '111', yearly: '222' } })[plan]![period],
|
|
200
|
+
customerId: async (billableId) => getLemonSqueezyCustomer(billableId), // for the portal
|
|
201
|
+
})
|
|
202
|
+
```
|
|
203
|
+
|
|
171
204
|
For development and testing there's `FakeBillingGateway`, which records all calls in arrays (`created`, `canceled`, `checkouts`, `portals`, `swaps`) and accepts the webhook signature `'valid'`.
|
|
172
205
|
|
|
173
206
|
### Gateway webhooks
|
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. */
|
|
@@ -694,6 +712,127 @@ declare class StripeBillingGateway implements BillingGateway {
|
|
|
694
712
|
private request;
|
|
695
713
|
}
|
|
696
714
|
|
|
715
|
+
declare class PaddleRequestError extends BasaltError {
|
|
716
|
+
readonly httpStatus: number;
|
|
717
|
+
constructor(httpStatus: number, message: string);
|
|
718
|
+
}
|
|
719
|
+
interface PaddleGatewayOptions {
|
|
720
|
+
/** Paddle API key (Bearer). */
|
|
721
|
+
apiKey: string;
|
|
722
|
+
/** Notification signing secret (`pdl_ntfset_…` / `ntfset_…`) used to verify webhooks. */
|
|
723
|
+
webhookSecret: string;
|
|
724
|
+
/** Resolves the Paddle Price ID (`pri_…`) for a plan + billing period. */
|
|
725
|
+
priceId: (plan: string, period: BillingPeriod) => string;
|
|
726
|
+
/** Resolves (or ensures) the Paddle Customer ID (`ctm_…`) for a billable entity. */
|
|
727
|
+
customerId: (billableId: string) => string | Promise<string>;
|
|
728
|
+
/**
|
|
729
|
+
* Extracts the billable id from a verified event. Default: reads
|
|
730
|
+
* `data.custom_data.billableId` — which the create/checkout calls set.
|
|
731
|
+
*/
|
|
732
|
+
resolveBillableId?: (event: unknown) => string | undefined;
|
|
733
|
+
/** Webhook timestamp tolerance in seconds. Default: 300 (5 minutes). */
|
|
734
|
+
tolerance?: number;
|
|
735
|
+
/** Injected fetch (tests). Default: global fetch. */
|
|
736
|
+
fetch?: typeof fetch;
|
|
737
|
+
/** Clock in ms (tests). Default: Date.now. */
|
|
738
|
+
now?: () => number;
|
|
739
|
+
/** API base, for tests/mocks. Default: https://api.paddle.com */
|
|
740
|
+
apiBase?: string;
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Paddle **Billing** gateway targeting the Paddle REST API directly — no SDK.
|
|
744
|
+
* HTTP goes through an injectable fetch; webhook signatures are verified with
|
|
745
|
+
* node:crypto using Paddle's `Paddle-Signature` scheme (`ts=…;h1=…`).
|
|
746
|
+
*
|
|
747
|
+
* Paddle is checkout-first: `createSubscription` and `createCheckoutSession`
|
|
748
|
+
* both create a **transaction** (the subscription materializes once the customer
|
|
749
|
+
* pays, and its id arrives on a `subscription.*` webhook via `gatewayRef`).
|
|
750
|
+
*/
|
|
751
|
+
declare class PaddleBillingGateway implements BillingGateway {
|
|
752
|
+
private readonly options;
|
|
753
|
+
readonly name = "paddle";
|
|
754
|
+
private readonly fetch;
|
|
755
|
+
private readonly now;
|
|
756
|
+
private readonly tolerance;
|
|
757
|
+
private readonly apiBase;
|
|
758
|
+
private readonly resolveBillableId;
|
|
759
|
+
constructor(options: PaddleGatewayOptions);
|
|
760
|
+
createSubscription(input: CreateSubscriptionInput): Promise<{
|
|
761
|
+
gatewayRef: string;
|
|
762
|
+
}>;
|
|
763
|
+
cancelSubscription(gatewayRef: string, options: {
|
|
764
|
+
atPeriodEnd: boolean;
|
|
765
|
+
}): Promise<void>;
|
|
766
|
+
createCheckoutSession(input: CheckoutInput): Promise<{
|
|
767
|
+
url: string;
|
|
768
|
+
id: string;
|
|
769
|
+
}>;
|
|
770
|
+
createPortalSession(input: PortalInput): Promise<{
|
|
771
|
+
url: string;
|
|
772
|
+
}>;
|
|
773
|
+
swapSubscription(gatewayRef: string, input: SwapInput): Promise<void>;
|
|
774
|
+
verifyWebhook(rawBody: string, signature: string | undefined): WebhookEvent | null;
|
|
775
|
+
private request;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
declare class LemonSqueezyRequestError extends BasaltError {
|
|
779
|
+
readonly httpStatus: number;
|
|
780
|
+
constructor(httpStatus: number, message: string);
|
|
781
|
+
}
|
|
782
|
+
interface LemonSqueezyGatewayOptions {
|
|
783
|
+
/** Lemon Squeezy API key (Bearer). */
|
|
784
|
+
apiKey: string;
|
|
785
|
+
/** Webhook signing secret used to verify `X-Signature`. */
|
|
786
|
+
webhookSecret: string;
|
|
787
|
+
/** Lemon Squeezy Store ID (needed to create checkouts). */
|
|
788
|
+
storeId: string;
|
|
789
|
+
/** Resolves the Lemon Squeezy Variant ID for a plan + billing period. */
|
|
790
|
+
variantId: (plan: string, period: BillingPeriod) => string;
|
|
791
|
+
/** Resolves the Lemon Squeezy Customer ID for a billable — required for the portal. */
|
|
792
|
+
customerId?: (billableId: string) => string | Promise<string>;
|
|
793
|
+
/**
|
|
794
|
+
* Extracts the billable id from a verified event. Default: reads
|
|
795
|
+
* `meta.custom_data.billableId` — which the checkout call sets.
|
|
796
|
+
*/
|
|
797
|
+
resolveBillableId?: (event: unknown) => string | undefined;
|
|
798
|
+
/** Injected fetch (tests). Default: global fetch. */
|
|
799
|
+
fetch?: typeof fetch;
|
|
800
|
+
/** API base, for tests/mocks. Default: https://api.lemonsqueezy.com/v1 */
|
|
801
|
+
apiBase?: string;
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Lemon Squeezy billing gateway targeting the REST API directly (JSON:API) — no
|
|
805
|
+
* SDK. Lemon Squeezy is a merchant-of-record and checkout-first, so
|
|
806
|
+
* `createSubscription`/`createCheckoutSession` create a **checkout**; the durable
|
|
807
|
+
* subscription id arrives on a `subscription_*` webhook via `gatewayRef`. Webhook
|
|
808
|
+
* signatures use the `X-Signature` scheme (HMAC-SHA256 hex over the raw body).
|
|
809
|
+
*/
|
|
810
|
+
declare class LemonSqueezyBillingGateway implements BillingGateway {
|
|
811
|
+
private readonly options;
|
|
812
|
+
readonly name = "lemonsqueezy";
|
|
813
|
+
private readonly fetch;
|
|
814
|
+
private readonly apiBase;
|
|
815
|
+
private readonly resolveBillableId;
|
|
816
|
+
constructor(options: LemonSqueezyGatewayOptions);
|
|
817
|
+
createSubscription(input: CreateSubscriptionInput): Promise<{
|
|
818
|
+
gatewayRef: string;
|
|
819
|
+
}>;
|
|
820
|
+
createCheckoutSession(input: CheckoutInput): Promise<{
|
|
821
|
+
url: string;
|
|
822
|
+
id: string;
|
|
823
|
+
}>;
|
|
824
|
+
private checkout;
|
|
825
|
+
cancelSubscription(gatewayRef: string, _options: {
|
|
826
|
+
atPeriodEnd: boolean;
|
|
827
|
+
}): Promise<void>;
|
|
828
|
+
createPortalSession(input: PortalInput): Promise<{
|
|
829
|
+
url: string;
|
|
830
|
+
}>;
|
|
831
|
+
swapSubscription(gatewayRef: string, input: SwapInput): Promise<void>;
|
|
832
|
+
verifyWebhook(rawBody: string, signature: string | undefined): WebhookEvent | null;
|
|
833
|
+
private request;
|
|
834
|
+
}
|
|
835
|
+
|
|
697
836
|
declare class NotSubscribedError extends BasaltError {
|
|
698
837
|
readonly status = 402;
|
|
699
838
|
constructor();
|
|
@@ -847,4 +986,4 @@ declare function billingRoutes(options: BillingRoutesOptions): BasaltRoute[];
|
|
|
847
986
|
*/
|
|
848
987
|
declare function billingWebhookRoute(gateway: BillingGateway): BasaltRoute;
|
|
849
988
|
|
|
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 };
|
|
989
|
+
export { type BillingGateway, type BillingPeriod, type BillingRoutesOptions, type CheckoutInput, type CreateSubscriptionInput, FakeBillingGateway, FakePaymentGateway, FeatureUnavailableError, type FeatureValue, GatewayUnsupportedError, type HandleEventResult, LemonSqueezyBillingGateway, type LemonSqueezyGatewayOptions, LemonSqueezyRequestError, MemoryPaymentStore, MemoryRecurringStore, MemorySubscriptionStore, MemoryUsageStore, MemoryWebhookStore, type Meter, type NewPayment, NotSubscribedError, PaddleBillingGateway, type PaddleGatewayOptions, PaddleRequestError, 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 = [];
|
|
@@ -303,6 +321,12 @@ var PaymentLedger = class {
|
|
|
303
321
|
let record;
|
|
304
322
|
try {
|
|
305
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
|
+
}
|
|
306
330
|
await this.store.setStatus(event.paymentId, status, {
|
|
307
331
|
amount: event.amount,
|
|
308
332
|
...event.raw !== void 0 ? { raw: event.raw } : {}
|
|
@@ -630,21 +654,264 @@ function formEncode(data) {
|
|
|
630
654
|
return Object.entries(data).map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join("&");
|
|
631
655
|
}
|
|
632
656
|
|
|
657
|
+
// src/drivers/paddle.ts
|
|
658
|
+
import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
659
|
+
import { BasaltError as BasaltError4 } from "@basaltkit/core";
|
|
660
|
+
var PaddleRequestError = class extends BasaltError4 {
|
|
661
|
+
constructor(httpStatus, message) {
|
|
662
|
+
super("BILLING_GATEWAY_ERROR", `Paddle request failed (${httpStatus}): ${message}`);
|
|
663
|
+
this.httpStatus = httpStatus;
|
|
664
|
+
}
|
|
665
|
+
httpStatus;
|
|
666
|
+
};
|
|
667
|
+
var EVENT_MAP2 = {
|
|
668
|
+
"subscription.canceled": "subscription.canceled",
|
|
669
|
+
"transaction.completed": "payment.succeeded",
|
|
670
|
+
"transaction.paid": "payment.succeeded",
|
|
671
|
+
"transaction.payment_failed": "payment.failed"
|
|
672
|
+
};
|
|
673
|
+
var PRORATION = {
|
|
674
|
+
create_prorations: "prorated_immediately",
|
|
675
|
+
none: "do_not_bill",
|
|
676
|
+
always_invoice: "full_immediately"
|
|
677
|
+
};
|
|
678
|
+
var PaddleBillingGateway = class {
|
|
679
|
+
constructor(options) {
|
|
680
|
+
this.options = options;
|
|
681
|
+
this.fetch = options.fetch ?? globalThis.fetch;
|
|
682
|
+
this.now = options.now ?? Date.now;
|
|
683
|
+
this.tolerance = options.tolerance ?? 300;
|
|
684
|
+
this.apiBase = options.apiBase ?? "https://api.paddle.com";
|
|
685
|
+
this.resolveBillableId = options.resolveBillableId ?? ((event) => event?.data?.custom_data?.["billableId"]);
|
|
686
|
+
}
|
|
687
|
+
options;
|
|
688
|
+
name = "paddle";
|
|
689
|
+
fetch;
|
|
690
|
+
now;
|
|
691
|
+
tolerance;
|
|
692
|
+
apiBase;
|
|
693
|
+
resolveBillableId;
|
|
694
|
+
async createSubscription(input) {
|
|
695
|
+
const customer = await this.options.customerId(input.billableId);
|
|
696
|
+
const created = await this.request("POST", "/transactions", {
|
|
697
|
+
items: [{ price_id: this.options.priceId(input.plan, input.period), quantity: 1 }],
|
|
698
|
+
customer_id: customer,
|
|
699
|
+
collection_mode: "automatic",
|
|
700
|
+
custom_data: { billableId: input.billableId }
|
|
701
|
+
});
|
|
702
|
+
return { gatewayRef: String(created.id) };
|
|
703
|
+
}
|
|
704
|
+
async cancelSubscription(gatewayRef, options) {
|
|
705
|
+
await this.request("POST", `/subscriptions/${gatewayRef}/cancel`, {
|
|
706
|
+
effective_from: options.atPeriodEnd ? "next_billing_period" : "immediately"
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
async createCheckoutSession(input) {
|
|
710
|
+
const customer = await this.options.customerId(input.billableId);
|
|
711
|
+
const created = await this.request("POST", "/transactions", {
|
|
712
|
+
items: [{ price_id: this.options.priceId(input.plan, input.period), quantity: 1 }],
|
|
713
|
+
customer_id: customer,
|
|
714
|
+
collection_mode: "automatic",
|
|
715
|
+
custom_data: { billableId: input.billableId },
|
|
716
|
+
checkout: { url: input.successUrl }
|
|
717
|
+
});
|
|
718
|
+
return { url: String(created.checkout?.url), id: String(created.id) };
|
|
719
|
+
}
|
|
720
|
+
async createPortalSession(input) {
|
|
721
|
+
const customer = await this.options.customerId(input.billableId);
|
|
722
|
+
const created = await this.request(
|
|
723
|
+
"POST",
|
|
724
|
+
`/customers/${customer}/portal-sessions`,
|
|
725
|
+
{}
|
|
726
|
+
);
|
|
727
|
+
return { url: String(created.urls?.general?.overview) };
|
|
728
|
+
}
|
|
729
|
+
async swapSubscription(gatewayRef, input) {
|
|
730
|
+
await this.request("PATCH", `/subscriptions/${gatewayRef}`, {
|
|
731
|
+
items: [{ price_id: this.options.priceId(input.plan, input.period), quantity: 1 }],
|
|
732
|
+
proration_billing_mode: PRORATION[input.prorationBehavior ?? "create_prorations"]
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
verifyWebhook(rawBody, signature) {
|
|
736
|
+
if (!signature) throw new WebhookInvalidError();
|
|
737
|
+
const parts = Object.fromEntries(
|
|
738
|
+
signature.split(";").map((pair) => {
|
|
739
|
+
const index = pair.indexOf("=");
|
|
740
|
+
return [pair.slice(0, index).trim(), pair.slice(index + 1)];
|
|
741
|
+
})
|
|
742
|
+
);
|
|
743
|
+
const timestamp = Number(parts.ts);
|
|
744
|
+
if (!Number.isFinite(timestamp) || !parts.h1) throw new WebhookInvalidError();
|
|
745
|
+
const expected = createHmac2("sha256", this.options.webhookSecret).update(`${parts.ts}:${rawBody}`).digest("hex");
|
|
746
|
+
const a = Buffer.from(expected);
|
|
747
|
+
const b = Buffer.from(parts.h1);
|
|
748
|
+
if (a.length !== b.length || !timingSafeEqual2(a, b)) throw new WebhookInvalidError();
|
|
749
|
+
if (Math.abs(this.now() / 1e3 - timestamp) > this.tolerance) throw new WebhookInvalidError();
|
|
750
|
+
let event;
|
|
751
|
+
try {
|
|
752
|
+
event = JSON.parse(rawBody);
|
|
753
|
+
} catch {
|
|
754
|
+
throw new WebhookInvalidError();
|
|
755
|
+
}
|
|
756
|
+
const type = event.event_type ? EVENT_MAP2[event.event_type] : void 0;
|
|
757
|
+
if (!type || !event.event_id) return null;
|
|
758
|
+
const billableId = this.resolveBillableId(event);
|
|
759
|
+
if (!billableId) return null;
|
|
760
|
+
const gatewayRef = event.data?.subscription_id ?? event.data?.id;
|
|
761
|
+
return { id: event.event_id, type, billableId, ...gatewayRef ? { gatewayRef } : {} };
|
|
762
|
+
}
|
|
763
|
+
async request(method, path, body) {
|
|
764
|
+
const response = await this.fetch(`${this.apiBase}${path}`, {
|
|
765
|
+
method,
|
|
766
|
+
headers: {
|
|
767
|
+
authorization: `Bearer ${this.options.apiKey}`,
|
|
768
|
+
"content-type": "application/json"
|
|
769
|
+
},
|
|
770
|
+
...body !== void 0 ? { body: JSON.stringify(body) } : {}
|
|
771
|
+
});
|
|
772
|
+
const text = await response.text();
|
|
773
|
+
const json = text ? JSON.parse(text) : {};
|
|
774
|
+
if (!response.ok) {
|
|
775
|
+
throw new PaddleRequestError(response.status, json.error?.detail ?? text ?? "unknown error");
|
|
776
|
+
}
|
|
777
|
+
return json.data ?? json;
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
// src/drivers/lemonsqueezy.ts
|
|
782
|
+
import { createHmac as createHmac3, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
783
|
+
import { BasaltError as BasaltError5 } from "@basaltkit/core";
|
|
784
|
+
var LemonSqueezyRequestError = class extends BasaltError5 {
|
|
785
|
+
constructor(httpStatus, message) {
|
|
786
|
+
super("BILLING_GATEWAY_ERROR", `Lemon Squeezy request failed (${httpStatus}): ${message}`);
|
|
787
|
+
this.httpStatus = httpStatus;
|
|
788
|
+
}
|
|
789
|
+
httpStatus;
|
|
790
|
+
};
|
|
791
|
+
var EVENT_MAP3 = {
|
|
792
|
+
subscription_cancelled: "subscription.canceled",
|
|
793
|
+
subscription_expired: "subscription.canceled",
|
|
794
|
+
subscription_payment_success: "payment.succeeded",
|
|
795
|
+
subscription_payment_failed: "payment.failed"
|
|
796
|
+
};
|
|
797
|
+
var JSON_API = "application/vnd.api+json";
|
|
798
|
+
var LemonSqueezyBillingGateway = class {
|
|
799
|
+
constructor(options) {
|
|
800
|
+
this.options = options;
|
|
801
|
+
this.fetch = options.fetch ?? globalThis.fetch;
|
|
802
|
+
this.apiBase = options.apiBase ?? "https://api.lemonsqueezy.com/v1";
|
|
803
|
+
this.resolveBillableId = options.resolveBillableId ?? ((event) => event?.meta?.custom_data?.["billableId"]);
|
|
804
|
+
}
|
|
805
|
+
options;
|
|
806
|
+
name = "lemonsqueezy";
|
|
807
|
+
fetch;
|
|
808
|
+
apiBase;
|
|
809
|
+
resolveBillableId;
|
|
810
|
+
async createSubscription(input) {
|
|
811
|
+
const checkout = await this.checkout(input.billableId, input.plan, input.period);
|
|
812
|
+
return { gatewayRef: String(checkout.id) };
|
|
813
|
+
}
|
|
814
|
+
async createCheckoutSession(input) {
|
|
815
|
+
const checkout = await this.checkout(input.billableId, input.plan, input.period, input.successUrl);
|
|
816
|
+
return { url: String(checkout.attributes?.url), id: String(checkout.id) };
|
|
817
|
+
}
|
|
818
|
+
async checkout(billableId, plan, period, redirectUrl) {
|
|
819
|
+
const created = await this.request("POST", "/checkouts", {
|
|
820
|
+
data: {
|
|
821
|
+
type: "checkouts",
|
|
822
|
+
attributes: {
|
|
823
|
+
checkout_data: { custom: { billableId } },
|
|
824
|
+
...redirectUrl ? { product_options: { redirect_url: redirectUrl } } : {}
|
|
825
|
+
},
|
|
826
|
+
relationships: {
|
|
827
|
+
store: { data: { type: "stores", id: this.options.storeId } },
|
|
828
|
+
variant: { data: { type: "variants", id: this.options.variantId(plan, period) } }
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
return created;
|
|
833
|
+
}
|
|
834
|
+
async cancelSubscription(gatewayRef, _options) {
|
|
835
|
+
await this.request("DELETE", `/subscriptions/${gatewayRef}`);
|
|
836
|
+
}
|
|
837
|
+
async createPortalSession(input) {
|
|
838
|
+
if (!this.options.customerId) {
|
|
839
|
+
throw new LemonSqueezyRequestError(500, "customerId resolver is required for the customer portal");
|
|
840
|
+
}
|
|
841
|
+
const customer = await this.options.customerId(input.billableId);
|
|
842
|
+
const found = await this.request("GET", `/customers/${customer}`);
|
|
843
|
+
return { url: String(found.attributes?.urls?.customer_portal) };
|
|
844
|
+
}
|
|
845
|
+
async swapSubscription(gatewayRef, input) {
|
|
846
|
+
const behavior = input.prorationBehavior ?? "create_prorations";
|
|
847
|
+
await this.request("PATCH", `/subscriptions/${gatewayRef}`, {
|
|
848
|
+
data: {
|
|
849
|
+
type: "subscriptions",
|
|
850
|
+
id: gatewayRef,
|
|
851
|
+
attributes: {
|
|
852
|
+
variant_id: this.options.variantId(input.plan, input.period),
|
|
853
|
+
disable_prorations: behavior === "none",
|
|
854
|
+
...behavior === "always_invoice" ? { invoice_immediately: true } : {}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
verifyWebhook(rawBody, signature) {
|
|
860
|
+
if (!signature) throw new WebhookInvalidError();
|
|
861
|
+
const expected = createHmac3("sha256", this.options.webhookSecret).update(rawBody).digest("hex");
|
|
862
|
+
const a = Buffer.from(expected);
|
|
863
|
+
const b = Buffer.from(signature);
|
|
864
|
+
if (a.length !== b.length || !timingSafeEqual3(a, b)) throw new WebhookInvalidError();
|
|
865
|
+
let event;
|
|
866
|
+
try {
|
|
867
|
+
event = JSON.parse(rawBody);
|
|
868
|
+
} catch {
|
|
869
|
+
throw new WebhookInvalidError();
|
|
870
|
+
}
|
|
871
|
+
const name = event.meta?.event_name;
|
|
872
|
+
const type = name ? EVENT_MAP3[name] : void 0;
|
|
873
|
+
if (!type) return null;
|
|
874
|
+
const billableId = this.resolveBillableId(event);
|
|
875
|
+
if (!billableId) return null;
|
|
876
|
+
const rawRef = event.data?.attributes?.subscription_id ?? event.data?.id;
|
|
877
|
+
const gatewayRef = rawRef !== void 0 ? String(rawRef) : void 0;
|
|
878
|
+
const id = `${name}:${gatewayRef ?? billableId}`;
|
|
879
|
+
return { id, type, billableId, ...gatewayRef ? { gatewayRef } : {} };
|
|
880
|
+
}
|
|
881
|
+
async request(method, path, body) {
|
|
882
|
+
const response = await this.fetch(`${this.apiBase}${path}`, {
|
|
883
|
+
method,
|
|
884
|
+
headers: {
|
|
885
|
+
authorization: `Bearer ${this.options.apiKey}`,
|
|
886
|
+
accept: JSON_API,
|
|
887
|
+
...body !== void 0 ? { "content-type": JSON_API } : {}
|
|
888
|
+
},
|
|
889
|
+
...body !== void 0 ? { body: JSON.stringify(body) } : {}
|
|
890
|
+
});
|
|
891
|
+
const text = await response.text();
|
|
892
|
+
const json = text ? JSON.parse(text) : {};
|
|
893
|
+
if (!response.ok) {
|
|
894
|
+
throw new LemonSqueezyRequestError(response.status, json.errors?.[0]?.detail ?? text ?? "unknown error");
|
|
895
|
+
}
|
|
896
|
+
return json.data ?? json;
|
|
897
|
+
}
|
|
898
|
+
};
|
|
899
|
+
|
|
633
900
|
// src/subscriptions.ts
|
|
634
|
-
import { BasaltError as
|
|
635
|
-
var NotSubscribedError = class extends
|
|
901
|
+
import { BasaltError as BasaltError6, parseDuration } from "@basaltkit/core";
|
|
902
|
+
var NotSubscribedError = class extends BasaltError6 {
|
|
636
903
|
status = 402;
|
|
637
904
|
constructor() {
|
|
638
905
|
super("BILLING_SUBSCRIPTION_REQUIRED", "An active subscription is required.");
|
|
639
906
|
}
|
|
640
907
|
};
|
|
641
|
-
var FeatureUnavailableError = class extends
|
|
908
|
+
var FeatureUnavailableError = class extends BasaltError6 {
|
|
642
909
|
status = 403;
|
|
643
910
|
constructor(feature) {
|
|
644
911
|
super("BILLING_FEATURE_UNAVAILABLE", `The feature "${feature}" is not available on this plan.`);
|
|
645
912
|
}
|
|
646
913
|
};
|
|
647
|
-
var QuotaExceededError = class extends
|
|
914
|
+
var QuotaExceededError = class extends BasaltError6 {
|
|
648
915
|
status = 402;
|
|
649
916
|
constructor(feature, remaining) {
|
|
650
917
|
super(
|
|
@@ -653,7 +920,7 @@ var QuotaExceededError = class extends BasaltError4 {
|
|
|
653
920
|
);
|
|
654
921
|
}
|
|
655
922
|
};
|
|
656
|
-
var GatewayUnsupportedError = class extends
|
|
923
|
+
var GatewayUnsupportedError = class extends BasaltError6 {
|
|
657
924
|
status = 501;
|
|
658
925
|
constructor(capability) {
|
|
659
926
|
super("BILLING_GATEWAY_UNSUPPORTED", `The billing gateway does not support "${capability}".`);
|
|
@@ -996,12 +1263,17 @@ export {
|
|
|
996
1263
|
FakePaymentGateway,
|
|
997
1264
|
FeatureUnavailableError,
|
|
998
1265
|
GatewayUnsupportedError,
|
|
1266
|
+
LemonSqueezyBillingGateway,
|
|
1267
|
+
LemonSqueezyRequestError,
|
|
999
1268
|
MemoryPaymentStore,
|
|
1000
1269
|
MemoryRecurringStore,
|
|
1001
1270
|
MemorySubscriptionStore,
|
|
1002
1271
|
MemoryUsageStore,
|
|
1003
1272
|
MemoryWebhookStore,
|
|
1004
1273
|
NotSubscribedError,
|
|
1274
|
+
PaddleBillingGateway,
|
|
1275
|
+
PaddleRequestError,
|
|
1276
|
+
PaymentAmountMismatchError,
|
|
1005
1277
|
PaymentLedger,
|
|
1006
1278
|
QuotaExceededError,
|
|
1007
1279
|
RecurringReferenceBilling,
|
|
@@ -1013,6 +1285,7 @@ export {
|
|
|
1013
1285
|
Subscriptions,
|
|
1014
1286
|
UnknownPlanError,
|
|
1015
1287
|
WebhookInvalidError,
|
|
1288
|
+
WebhookSecretMissingError,
|
|
1016
1289
|
addInterval,
|
|
1017
1290
|
assertMinorUnits,
|
|
1018
1291
|
billingRoutes,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basaltkit/subscriptions",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.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/
|
|
18
|
-
"@basaltkit/
|
|
17
|
+
"@basaltkit/core": "^1.0.0",
|
|
18
|
+
"@basaltkit/fastify": "^1.4.0"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
21
|
"zod": "^3.24.0 || ^4.0.0"
|