@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 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 BasaltError4, parseDuration } from "@basaltkit/core";
635
- var NotSubscribedError = class extends BasaltError4 {
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 BasaltError4 {
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 BasaltError4 {
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 BasaltError4 {
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.1.0",
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/fastify": "^1.0.0",
18
- "@basaltkit/core": "^1.0.0"
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"