diff --git a/src/client/client.class.ts b/src/client/client.class.ts index c5ea0f0..8ea3f86 100644 --- a/src/client/client.class.ts +++ b/src/client/client.class.ts @@ -1,7 +1,9 @@ import { + constructEvent, createCheckout, getUser, listAllCheckouts, + listAllCustomers, listAllDiscounts, listAllFiles, listAllLicenseKeyInstances, @@ -14,6 +16,7 @@ import { listAllSubscriptions, listAllVariants, retrieveCheckout, + retrieveCustomer, retrieveDiscount, retrieveFile, retrieveLicenseKey, @@ -32,6 +35,7 @@ import type { CreateCheckoutOptions, GetUserOptions, ListAllCheckoutsOptions, + ListAllCustomersOptions, ListAllDiscountsOptions, ListAllFilesOptions, ListAllLicenseKeyInstancesOptions, @@ -44,6 +48,7 @@ import type { ListAllSubscriptionsOptions, ListAllVariantsOptions, RetrieveCheckoutOptions, + RetrieveCustomerOptions, RetrieveDiscountOptions, RetrieveFileOptions, RetrieveLicenseKeyInstanceOptions, @@ -57,6 +62,7 @@ import type { RetrieveVariantOptions, UpdateSubscriptionOptions, } from "~/modules"; +import { LemonsqueezyWebhookPayload } from "~/modules/webhook/webhook.types"; export class LemonsqueezyClient { private _apiKey: string; @@ -573,4 +579,59 @@ export class LemonsqueezyClient { ...options, }); } + + /** + * Retrieve customer + * + * @description Retrieves the customer with the given ID + * + * @docs https://docs.lemonsqueezy.com/api/customers#retrieve-a-customer + * + * @param {String} options.id - The ID of the customer to retrieve + * + * @returns A customer object + */ + public async retrieveCustomer(options: RetrieveCustomerOptions) { + return retrieveCustomer({ + apiKey: this._apiKey, + ...options, + }); + } + + /** + * List all customers + * + * @description Returns a paginated list of customers + * + * @docs https://docs.lemonsqueezy.com/api/customers#list-all-customers + * + * @param {Object} [options] + * + * @returns Returns a paginated list of customer objects ordered by `created_at` (descending) + */ + public async listAllCustomers(options: ListAllCustomersOptions = {}) { + return listAllCustomers({ + apiKey: this._apiKey, + ...options, + }); + } + + /** + * Construct webhook event + * + * @description Constructs an event object + * + * @param {String | Uint8Array} payload - Raw text body received from Lemonsqueezy + * @param {String} header - Value of the `X-Signature` header received from Lemonsqueezy + * @param {String} secret - Your Lemonsqueezy webhook signing secret + * + * @returns An event object + */ + public constructEvent( + payload: LemonsqueezyWebhookPayload, + header: string, + secret: string + ) { + return constructEvent(payload, header, secret); + } } diff --git a/src/index.ts b/src/index.ts index 8efe05c..b3fb13e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,10 @@ export { LemonsqueezyClient } from "./client"; export { + constructEvent, createCheckout, getUser, listAllCheckouts, + listAllCustomers, listAllDiscounts, listAllFiles, listAllLicenseKeyInstances, @@ -14,6 +16,7 @@ export { listAllSubscriptions, listAllVariants, retrieveCheckout, + retrieveCustomer, retrieveDiscount, retrieveFile, retrieveLicenseKey, diff --git a/src/modules/checkout/checkout.types.ts b/src/modules/checkout/checkout.types.ts index d27a04a..9e38ff6 100644 --- a/src/modules/checkout/checkout.types.ts +++ b/src/modules/checkout/checkout.types.ts @@ -11,19 +11,19 @@ export interface LemonsqueezyBillingAddress { * * @see https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 */ - country: string; + country?: string; /** * A pre-filled billing address zip/postal code */ - zip: string; + zip?: string; } export interface LemonsqueezyCheckoutData { - billing_address: LemonsqueezyBillingAddress; + billing_address?: LemonsqueezyBillingAddress; /** * An object containing any custom data to be passed to the checkout */ - custom?: Array; + custom?: Record; /** * A pre-filled discount code */ @@ -31,11 +31,11 @@ export interface LemonsqueezyCheckoutData { /** * A pre-filled email address */ - email: string; + email?: string; /** * A pre-filled name */ - name: string; + name?: string; /** * A pre-filled tax number */ @@ -73,6 +73,10 @@ export interface LemonsqueezyCheckoutOptions { * If `false`, hide the product media */ media?: boolean; + /** + * If false, hide the "You will be charged..." subscription preview text + */ + subscription_preview?: boolean; } export interface LemonsqueezyCheckoutPreview { @@ -96,7 +100,7 @@ export interface LemonsqueezyProductOptions { /** * A custom description for the product */ - description: string; + description?: string; /** * An array of variant IDs to enable for this checkout. If this is empty, all variants will be enabled */ @@ -108,23 +112,23 @@ export interface LemonsqueezyProductOptions { /** * A custom name for the product */ - name: string; + name?: string; /** * A custom text to use for the order receipt email button */ - receipt_button_text: string; + receipt_button_text?: string; /** * A custom URL to use for the order receipt email button */ - receipt_link_url: string; + receipt_link_url?: string; /** * A custom thank you note to use for the order receipt email */ - receipt_thank_you_note: string; + receipt_thank_you_note?: string; /** * A custom URL to redirect to after a successful purchase */ - redirect_url: string; + redirect_url?: string; } /** @@ -217,7 +221,7 @@ export interface CreateCheckoutOptions extends SharedLemonsqueezyOptions { * future (i.e. the customer is moved to a different subscription "tier") the * new variant's price will be used from that moment forward. */ - custom_price: number; + custom_price?: number | null; /** * An ISO-8601 formatted date-time string indicating when the checkout expires * diff --git a/src/modules/customer/README.md b/src/modules/customer/README.md new file mode 100644 index 0000000..6b76298 --- /dev/null +++ b/src/modules/customer/README.md @@ -0,0 +1,28 @@ +## 👤 Customer + +[![Docs](https://img.shields.io/badge/-Docs-blue.svg?style=for-the-badge)](https://docs.lemonsqueezy.com/api/customers) + +```typescript +import { LemonsqueezyClient } from 'lemonsqueezy.ts'; + +const client = new LemonsqueezyClient('YOUR_API_KEY'); + +const customer = await client.retrieveCustomer({ + id: '...', +}); + +const customers = await client.listAllCustomers(); +``` + +```typescript +import { retrieveCustomer, listAllCustomers } from 'lemonsqueezy.ts/customer'; + +const customer = await retrieveCustomer({ + apiKey: 'YOUR_API_KEY', + id: '...', +}); + +const customers = await listAllCustomers({ + apiKey: 'YOUR_API_KEY', +}); +``` diff --git a/src/modules/customer/customer.action.ts b/src/modules/customer/customer.action.ts new file mode 100644 index 0000000..c3f1016 --- /dev/null +++ b/src/modules/customer/customer.action.ts @@ -0,0 +1,56 @@ +import { + ListAllCustomersOptions, + ListAllCustomersResult, + RetrieveCustomerOptions, + RetrieveCustomerResult, +} from "./customer.types"; +import type { SharedModuleOptions } from "~/shared"; +import { requestLemonSqueeze } from "~/shared"; + +/** + * List all customers + * + * @description Returns a paginated list of customers + * + * @docs https://docs.lemonsqueezy.com/api/customers#list-all-customers + * + * @param {Object} [options] + * + * @returns Returns a paginated list of customer objects ordered by `created_at` (descending) + */ +export async function listAllCustomers( + options: ListAllCustomersOptions & SharedModuleOptions +): Promise { + const { storeId, email, ...rest } = options; + + return requestLemonSqueeze({ + params: { + ...(storeId ? { store_id: storeId } : {}), + ...(email ? { email: email } : {}), + }, + path: "/customers", + ...rest, + }); +} + +/** + * Retrieve customer + * + * @description Retrieves the customer with the given ID + * + * @docs https://docs.lemonsqueezy.com/api/customers#retrieve-a-customer + * + * @param {String} options.id - The ID of the customer to retrieve + * + * @returns A customer object + */ +export async function retrieveCustomer( + options: RetrieveCustomerOptions & SharedModuleOptions +): Promise { + const { id, ...rest } = options; + + return requestLemonSqueeze({ + path: `/customers/${id}`, + ...rest, + }); +} diff --git a/src/modules/customer/customer.test.ts b/src/modules/customer/customer.test.ts new file mode 100644 index 0000000..25fe2ac --- /dev/null +++ b/src/modules/customer/customer.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, beforeAll } from "vitest"; + +import { listAllCustomers, retrieveCustomer } from "."; + +describe.concurrent("Customer", () => { + const apiKey = process.env.LEMON_SQUEEZY_API_KEY as string; + + beforeAll(() => { + if (!apiKey) throw "No LEMON_SQUEEZY_API_KEY environment variable found"; + }); + + it("Retrieve customer", async () => { + const customers = await listAllCustomers({ + apiKey, + }); + if (!customers.data.length) throw new Error("No customers found"); + + const customer = await retrieveCustomer({ + apiKey, + id: customers.data.at(0)!.id, + }); + + expect(customer).toBeDefined(); + expect(customer.data).toBeDefined(); + expect(customer.data).not.toBeNull(); + expect(customer.errors).toBeUndefined(); + }); + + it("List all customers", async () => { + const customers = await listAllCustomers({ + apiKey, + }); + + expect(customers).toBeDefined(); + expect(Array.isArray(customers.data)).toBe(true); + expect(customers.errors).toBeUndefined(); + }); +}); diff --git a/src/modules/customer/customer.types.ts b/src/modules/customer/customer.types.ts new file mode 100644 index 0000000..f689136 --- /dev/null +++ b/src/modules/customer/customer.types.ts @@ -0,0 +1,103 @@ +import type { + BaseLemonsqueezyResponse, + LemonsqueezyDataType, + PaginatedBaseLemonsqueezyResponse, + SharedLemonsqueezyOptions, +} from "~/shared"; + +export interface LemonsqueezyCustomer { + attributes: { + /** + * The ID of the store this customer belongs to + */ + store_id: number; + /** + * The full name of the customer + */ + name: string; + /** + * The email address of the customer + */ + email: string; + /** + * The email marketing status of the customer. + */ + status: + | "subscribed" + | "unsubscribed" + | "archived" + | "requires_verification" + | "invalid_email" + | "bounced"; + /** + * The city of the customer + */ + city: string; + /** + * The region of the customer + */ + region: string; + /** + * The country of the customer + */ + country: string; + /** + * A positive integer in cents representing the total revenue from the customer (USD). + */ + total_revenue_currency: number; + /** + * A positive integer in cents representing the monthly recurring revenue from the customer (USD). + */ + mrr: number; + /** + * The formatted status of the customer. + */ + status_formatted: string; + /** + * The formatted country of the customer. + */ + country_formatted: string; + /** + * A human-readable string representing the total revenue from the customer (e.g. $9.99). + */ + total_revenue_currency_formatted: string; + /** + * A human-readable string representing the monthly recurring revenue from the customer (e.g. $9.99). + */ + mrr_formatted: string; + /** + * An ISO-8601 formatted date-time string indicating when the object was created + * @see https://en.wikipedia.org/wiki/ISO_8601 + */ + created_at: Date; + /** + * An ISO-8601 formatted date-time string indicating when the object was last updated + * @see https://en.wikipedia.org/wiki/ISO_8601 + */ + updated_at: Date; + }; + type: LemonsqueezyDataType.customers; + id: string; +} + +export interface ListAllCustomersOptions extends SharedLemonsqueezyOptions { + /** + * Only return checkouts belonging to the store with this ID + */ + storeId?: string; + /** + * Only return customers where the email field is equal to this email address. + */ + email?: string; +} + +export type ListAllCustomersResult = PaginatedBaseLemonsqueezyResponse< + Array +>; + +export interface RetrieveCustomerOptions extends SharedLemonsqueezyOptions { + id: string; +} + +export type RetrieveCustomerResult = + BaseLemonsqueezyResponse; diff --git a/src/modules/customer/index.ts b/src/modules/customer/index.ts new file mode 100644 index 0000000..9974472 --- /dev/null +++ b/src/modules/customer/index.ts @@ -0,0 +1,16 @@ +import { listAllCustomers, retrieveCustomer } from "./customer.action"; + +export { listAllCustomers, retrieveCustomer }; + +export type { + LemonsqueezyCustomer, + ListAllCustomersOptions, + ListAllCustomersResult, + RetrieveCustomerOptions, + RetrieveCustomerResult, +} from "./customer.types"; + +export default { + listAllCustomers, + retrieveCustomer, +} as const; diff --git a/src/modules/index.ts b/src/modules/index.ts index 38664d1..1d72bb1 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -1,4 +1,5 @@ export * from "./checkout"; +export * from "./customer"; export * from "./discount"; export * from "./file"; export * from "./licenseKey"; @@ -11,3 +12,4 @@ export * from "./subscription"; export * from "./subscriptionInvoice"; export * from "./user"; export * from "./variant"; +export * from "./webhook"; diff --git a/src/modules/webhook/README.md b/src/modules/webhook/README.md new file mode 100644 index 0000000..810f0f1 --- /dev/null +++ b/src/modules/webhook/README.md @@ -0,0 +1,53 @@ +## 🪝 Webhook + +[![Docs](https://img.shields.io/badge/-Docs-blue.svg?style=for-the-badge)](https://docs.lemonsqueezy.com/help/webhooks) + +```typescript +import { LemonsqueezyClient } from "lemonsqueezy.ts"; + +const client = new LemonsqueezyClient("YOUR_API_KEY"); + +const event = client.constructEvent( + "RAW_REQUEST_BODY", + "X-Signature HEADER_VALUE", + "YOUR_WEBHOOK_SECRET" +) + +switch (event.type) { + case "order_created": + const order = event.data; + break; + case "order_refunded": + const order = event.data; + break; + case "subscription_created": + const subscription = event.data; + break; + default: + break; +} +``` + +```typescript +import { constructEvent } from "lemonsqueezy.ts"; + +const event = constructEvent( + "RAW_REQUEST_BODY", + "X-Signature HEADER_VALUE", + "YOUR_WEBHOOK_SECRET" +) + +switch (event.type) { + case "order_created": + const order = event.data; + break; + case "order_refunded": + const order = event.data; + break; + case "subscription_created": + const subscription = event.data; + break; + default: + break; +} +``` \ No newline at end of file diff --git a/src/modules/webhook/index.ts b/src/modules/webhook/index.ts new file mode 100644 index 0000000..3496f7a --- /dev/null +++ b/src/modules/webhook/index.ts @@ -0,0 +1,9 @@ +import { constructEvent } from "./webhook.action"; + +export { constructEvent }; + +export type { LemonsqueezyWebhookEvent } from "./webhook.types"; + +export default { + constructEvent, +} as const; diff --git a/src/modules/webhook/webhook.action.ts b/src/modules/webhook/webhook.action.ts new file mode 100644 index 0000000..dd63802 --- /dev/null +++ b/src/modules/webhook/webhook.action.ts @@ -0,0 +1,54 @@ +import crypto from "crypto"; +import { + LemonsqueezyWebhookEvent, + LemonsqueezyWebhookPayload, + LemonsqueezyWebhookPayloadJson, +} from "./webhook.types"; + +function verifySignature( + payload: LemonsqueezyWebhookPayload, + header: string, + secret: string +) { + const hmac = crypto.createHmac("sha256", secret); + const digest = Buffer.from(hmac.update(payload).digest("hex"), "utf8"); + const signature = Buffer.from(header, "utf8"); + + return crypto.timingSafeEqual(digest, signature); +} + +/** + * Construct event + * + * @description Constructs an event object + * + * @param {String} payload - Raw text body received from Lemonsqueezy + * @param {String} header - Value of the `X-Signature` header received from Lemonsqueezy + * @param {String} secret - Your Lemonsqueezy webhook signing secret + * + * @returns An event object + */ +export function constructEvent( + payload: LemonsqueezyWebhookPayload, + header: string, + secret: string +): LemonsqueezyWebhookEvent { + if (!verifySignature(payload, header, secret)) { + throw new Error("Invalid signature."); + } + + const jsonPayload: LemonsqueezyWebhookPayloadJson = + payload instanceof Uint8Array + ? JSON.parse(new TextDecoder("utf8").decode(payload)) + : JSON.parse(payload); + + const { meta, data } = jsonPayload; + + const event: LemonsqueezyWebhookEvent = { + type: meta.event_name, + custom_data: meta.custom_data, + data, + }; + + return event; +} diff --git a/src/modules/webhook/webhook.test.ts b/src/modules/webhook/webhook.test.ts new file mode 100644 index 0000000..e46b0a0 --- /dev/null +++ b/src/modules/webhook/webhook.test.ts @@ -0,0 +1 @@ +// TODO: Create tests for webhook module diff --git a/src/modules/webhook/webhook.types.ts b/src/modules/webhook/webhook.types.ts new file mode 100644 index 0000000..2448502 --- /dev/null +++ b/src/modules/webhook/webhook.types.ts @@ -0,0 +1,131 @@ +import { LemonsqueezyLicenseKey } from "../licenseKey"; +import { LemonsqueezyOrder } from "../order"; +import { LemonsqueezySubscription } from "../subscription"; +import { LemonsqueezySubscriptionInvoice } from "../subscriptionInvoice"; + +export type LemonsqueezyWebhookPayload = string | Uint8Array; + +enum LemonsqueezyEventNames { + OrderCreated = "order_created", + OrderRefunded = "order_refunded", + SubscriptionCreated = "subscription_created", + SubscriptionUpdated = "subscription_updated", + SubscriptionCancelled = "subscription_cancelled", + SubscriptionResumed = "subscription_resumed", + SubscriptionExpired = "subscription_expired", + SubscriptionPaused = "subscription_paused", + SubscriptionUnpaused = "subscription_unpaused", + SubscriptionPaymentSuccess = "subscription_payment_success", + SubscriptionPaymentFailed = "subscription_payment_failed", + SubscriptionPaymentRecovered = "subscription_payment_recovered", + LicenseKeyCreated = "license_key_created", + LicenseKeyUpdated = "license_key_updated", +} + +export type LemonsqueezyWebhookPayloadJson = { + meta: { + event_name: LemonsqueezyEventNames; + custom_data?: Record; + }; + data: any; +}; + +type OrderCreatedEvent = { + type: LemonsqueezyEventNames.OrderCreated; + custom_data?: Record; + data: LemonsqueezyOrder; +}; + +type OrderRefundedEvent = { + type: LemonsqueezyEventNames.OrderRefunded; + custom_data?: Record; + data: LemonsqueezyOrder; +}; + +type SubscriptionCreatedEvent = { + type: LemonsqueezyEventNames.SubscriptionCreated; + custom_data?: Record; + data: LemonsqueezySubscription; +}; + +type SubscriptionUpdatedEvent = { + type: LemonsqueezyEventNames.SubscriptionUpdated; + custom_data?: Record; + data: LemonsqueezySubscription; +}; + +type SubscriptionCancelledEvent = { + type: LemonsqueezyEventNames.SubscriptionCancelled; + custom_data?: Record; + data: LemonsqueezySubscription; +}; + +type SubscriptionResumedEvent = { + type: LemonsqueezyEventNames.SubscriptionResumed; + custom_data?: Record; + data: LemonsqueezySubscription; +}; + +type SubscriptionExpiredEvent = { + type: LemonsqueezyEventNames.SubscriptionExpired; + custom_data?: Record; + data: LemonsqueezySubscription; +}; + +type SubscriptionPausedEvent = { + type: LemonsqueezyEventNames.SubscriptionPaused; + custom_data?: Record; + data: LemonsqueezySubscription; +}; + +type SubscriptionUnpausedEvent = { + type: LemonsqueezyEventNames.SubscriptionUnpaused; + custom_data?: Record; + data: LemonsqueezySubscription; +}; + +type SubscriptionPaymentSuccessEvent = { + type: LemonsqueezyEventNames.SubscriptionPaymentSuccess; + custom_data?: Record; + data: LemonsqueezySubscriptionInvoice; +}; + +type SubscriptionPaymentFailedEvent = { + type: LemonsqueezyEventNames.SubscriptionPaymentFailed; + custom_data?: Record; + data: LemonsqueezySubscriptionInvoice; +}; + +type SubscriptionPaymentRecoveredEvent = { + type: LemonsqueezyEventNames.SubscriptionPaymentRecovered; + custom_data?: Record; + data: LemonsqueezySubscriptionInvoice; +}; + +type LicenseKeyCreatedEvent = { + type: LemonsqueezyEventNames.LicenseKeyCreated; + custom_data?: Record; + data: LemonsqueezyLicenseKey; +}; + +type LicenseKeyUpdatedEvent = { + type: LemonsqueezyEventNames.LicenseKeyUpdated; + custom_data?: Record; + data: LemonsqueezyLicenseKey; +}; + +export type LemonsqueezyWebhookEvent = + | OrderCreatedEvent + | OrderRefundedEvent + | SubscriptionCreatedEvent + | SubscriptionUpdatedEvent + | SubscriptionCancelledEvent + | SubscriptionResumedEvent + | SubscriptionExpiredEvent + | SubscriptionPausedEvent + | SubscriptionUnpausedEvent + | SubscriptionPaymentSuccessEvent + | SubscriptionPaymentFailedEvent + | SubscriptionPaymentRecoveredEvent + | LicenseKeyCreatedEvent + | LicenseKeyUpdatedEvent; diff --git a/src/shared/shared.types.ts b/src/shared/shared.types.ts index dcf4ef6..71c5b8d 100644 --- a/src/shared/shared.types.ts +++ b/src/shared/shared.types.ts @@ -33,6 +33,7 @@ export interface LemonsqueezyOptions< export enum LemonsqueezyDataType { checkouts = "checkouts", + customers = "customers", discounts = "discounts", files = "files", license_key_instances = "license-key-instances", diff --git a/src/types.ts b/src/types.ts index 6559860..f7a2099 100644 --- a/src/types.ts +++ b/src/types.ts @@ -100,3 +100,5 @@ export type { RetrieveVariantOptions, RetrieveVariantResult, } from "~/modules/variant/variant.types"; + +export type { LemonsqueezyWebhookEvent } from "~/modules/webhook/webhook.types";