@basaltkit/subscriptions 2.2.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 +122 -1
- package/dist/index.js +252 -5
- package/package.json +2 -2
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
|
@@ -712,6 +712,127 @@ declare class StripeBillingGateway implements BillingGateway {
|
|
|
712
712
|
private request;
|
|
713
713
|
}
|
|
714
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
|
+
|
|
715
836
|
declare class NotSubscribedError extends BasaltError {
|
|
716
837
|
readonly status = 402;
|
|
717
838
|
constructor();
|
|
@@ -865,4 +986,4 @@ declare function billingRoutes(options: BillingRoutesOptions): BasaltRoute[];
|
|
|
865
986
|
*/
|
|
866
987
|
declare function billingWebhookRoute(gateway: BillingGateway): BasaltRoute;
|
|
867
988
|
|
|
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 };
|
|
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
|
@@ -654,21 +654,264 @@ function formEncode(data) {
|
|
|
654
654
|
return Object.entries(data).map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join("&");
|
|
655
655
|
}
|
|
656
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
|
+
|
|
657
900
|
// src/subscriptions.ts
|
|
658
|
-
import { BasaltError as
|
|
659
|
-
var NotSubscribedError = class extends
|
|
901
|
+
import { BasaltError as BasaltError6, parseDuration } from "@basaltkit/core";
|
|
902
|
+
var NotSubscribedError = class extends BasaltError6 {
|
|
660
903
|
status = 402;
|
|
661
904
|
constructor() {
|
|
662
905
|
super("BILLING_SUBSCRIPTION_REQUIRED", "An active subscription is required.");
|
|
663
906
|
}
|
|
664
907
|
};
|
|
665
|
-
var FeatureUnavailableError = class extends
|
|
908
|
+
var FeatureUnavailableError = class extends BasaltError6 {
|
|
666
909
|
status = 403;
|
|
667
910
|
constructor(feature) {
|
|
668
911
|
super("BILLING_FEATURE_UNAVAILABLE", `The feature "${feature}" is not available on this plan.`);
|
|
669
912
|
}
|
|
670
913
|
};
|
|
671
|
-
var QuotaExceededError = class extends
|
|
914
|
+
var QuotaExceededError = class extends BasaltError6 {
|
|
672
915
|
status = 402;
|
|
673
916
|
constructor(feature, remaining) {
|
|
674
917
|
super(
|
|
@@ -677,7 +920,7 @@ var QuotaExceededError = class extends BasaltError4 {
|
|
|
677
920
|
);
|
|
678
921
|
}
|
|
679
922
|
};
|
|
680
|
-
var GatewayUnsupportedError = class extends
|
|
923
|
+
var GatewayUnsupportedError = class extends BasaltError6 {
|
|
681
924
|
status = 501;
|
|
682
925
|
constructor(capability) {
|
|
683
926
|
super("BILLING_GATEWAY_UNSUPPORTED", `The billing gateway does not support "${capability}".`);
|
|
@@ -1020,12 +1263,16 @@ export {
|
|
|
1020
1263
|
FakePaymentGateway,
|
|
1021
1264
|
FeatureUnavailableError,
|
|
1022
1265
|
GatewayUnsupportedError,
|
|
1266
|
+
LemonSqueezyBillingGateway,
|
|
1267
|
+
LemonSqueezyRequestError,
|
|
1023
1268
|
MemoryPaymentStore,
|
|
1024
1269
|
MemoryRecurringStore,
|
|
1025
1270
|
MemorySubscriptionStore,
|
|
1026
1271
|
MemoryUsageStore,
|
|
1027
1272
|
MemoryWebhookStore,
|
|
1028
1273
|
NotSubscribedError,
|
|
1274
|
+
PaddleBillingGateway,
|
|
1275
|
+
PaddleRequestError,
|
|
1029
1276
|
PaymentAmountMismatchError,
|
|
1030
1277
|
PaymentLedger,
|
|
1031
1278
|
QuotaExceededError,
|
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",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
],
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@basaltkit/core": "^1.0.0",
|
|
18
|
-
"@basaltkit/fastify": "^1.
|
|
18
|
+
"@basaltkit/fastify": "^1.4.0"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
21
|
"zod": "^3.24.0 || ^4.0.0"
|