From 448100fe34439940a584911b0b846f46b4acea2b Mon Sep 17 00:00:00 2001 From: Zicklag Date: Thu, 5 Dec 2024 09:24:39 -0600 Subject: [PATCH] feat: implement minimal billing integration with LemonSqueezy. --- .env.local | 12 +- .typos.toml | 2 +- package.json | 2 + pnpm-lock.yaml | 17 ++ src/app.html | 1 + src/hooks.ts | 5 + src/lib/billing.ts | 173 ++++++++++++++++++ src/routes/(app)/[username]/+layout.server.ts | 9 +- src/routes/(app)/[username]/+layout.svelte | 32 +++- .../components/ManageSubscriptionModal.svelte | 107 +++++++++++ .../cancelBillingSubscription/+server.ts | 11 ++ .../getBillingCheckoutLink/+server.ts | 10 + .../getBillingMethodUpdateLink/+server.ts | 11 ++ .../resumeBillingSubscription/+server.ts | 11 ++ .../__internal__/admin/+page.svelte | 2 - .../admin/dns/set/+page.server.ts | 29 --- .../__internal__/admin/dns/set/+page.svelte | 28 --- .../lemonsqueezy-webhook/+server.ts | 33 ++++ src/shims.d.ts | 39 ++++ 19 files changed, 467 insertions(+), 67 deletions(-) create mode 100644 src/lib/billing.ts create mode 100644 src/routes/(app)/[username]/components/ManageSubscriptionModal.svelte create mode 100644 src/routes/(app)/[username]/settings/cancelBillingSubscription/+server.ts create mode 100644 src/routes/(app)/[username]/settings/getBillingCheckoutLink/+server.ts create mode 100644 src/routes/(app)/[username]/settings/getBillingMethodUpdateLink/+server.ts create mode 100644 src/routes/(app)/[username]/settings/resumeBillingSubscription/+server.ts delete mode 100644 src/routes/(internal)/__internal__/admin/dns/set/+page.server.ts delete mode 100644 src/routes/(internal)/__internal__/admin/dns/set/+page.svelte create mode 100644 src/routes/(internal)/__internal__/lemonsqueezy-webhook/+server.ts create mode 100644 src/shims.d.ts diff --git a/.env.local b/.env.local index bb108f23..ccce33ae 100644 --- a/.env.local +++ b/.env.local @@ -48,4 +48,14 @@ GH_CLIENT_SECRET= # Bsky BSKY_IDENTIFIER= -BSKY_PSWD= \ No newline at end of file +BSKY_PSWD= + +# Lemon Squeezy ( billing ) +LEMONSQUEEZY_API_KEY= +LEMONSQUEEZY_STORE_ID= +LEMONSQUEEZY_WEIRD_NERD_VARIANT_ID= + +# You'll need to set this to something publicly accessible and forward to your +# local host using something like localhost.run +PUBLIC_LEMONSQUEEZY_WEBHOOK_DOMAIN= +LEMONSQUEEZY_WEBHOOK_SECRET=testwebhooksecret \ No newline at end of file diff --git a/.typos.toml b/.typos.toml index ed06f084..a433fc99 100644 --- a/.typos.toml +++ b/.typos.toml @@ -1,5 +1,5 @@ [files] -extend-exclude = ["src/lib/pow/wasm/*", "pnpm-lock.yaml"] +extend-exclude = ["src/lib/pow/wasm/*", "pnpm-lock.yaml", ".env.local"] [default.extend-words] ratatui = "ratatui" diff --git a/package.json b/package.json index 7ab7ea35..899f4766 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@dicebear/core": "^9.2.2", "@floating-ui/dom": "^1.6.12", "@iconify/svelte": "^4.0.2", + "@lemonsqueezy/lemonsqueezy.js": "^4.0.0", "@lezer/highlight": "^1.2.1", "@rodrigodagostino/svelte-sortable-list": "^0.10.8", "@skeletonlabs/skeleton": "^2.10.3", @@ -78,6 +79,7 @@ "svelte": "^5.0.0", "svelte-check": "^3.8.6", "tailwindcss": "^3.4.15", + "timeago.js": "^4.0.2", "typescript": "^5.7.2", "underscore": "^1.13.7", "undici": "^6.21.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77511c5a..24753739 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@iconify/svelte': specifier: ^4.0.2 version: 4.0.2(svelte@5.2.9) + '@lemonsqueezy/lemonsqueezy.js': + specifier: ^4.0.0 + version: 4.0.0 '@lezer/highlight': specifier: ^1.2.1 version: 1.2.1 @@ -215,6 +218,9 @@ importers: tailwindcss: specifier: ^3.4.15 version: 3.4.15 + timeago.js: + specifier: ^4.0.2 + version: 4.0.2 typescript: specifier: ^5.7.2 version: 5.7.2 @@ -865,6 +871,10 @@ packages: '@leichtgewicht/ip-codec@2.0.5': resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} + '@lemonsqueezy/lemonsqueezy.js@4.0.0': + resolution: {integrity: sha512-xcY1/lDrY7CpIF98WKiL1ElsfoVhddP7FT0fw7ssOzrFqQsr44HgolKrQZxd9SywsCPn12OTOUieqDIokI3mFg==} + engines: {node: '>=20'} + '@lezer/common@1.2.3': resolution: {integrity: sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==} @@ -2920,6 +2930,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + timeago.js@4.0.2: + resolution: {integrity: sha512-a7wPxPdVlQL7lqvitHGGRsofhdwtkoSXPGATFuSOA2i1ZNQEPLrGnj68vOp2sOJTCFAQVXPeNMX/GctBaO9L2w==} + tiny-glob@0.2.9: resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} @@ -3814,6 +3827,8 @@ snapshots: '@leichtgewicht/ip-codec@2.0.5': {} + '@lemonsqueezy/lemonsqueezy.js@4.0.0': {} + '@lezer/common@1.2.3': {} '@lezer/css@1.1.9': @@ -6003,6 +6018,8 @@ snapshots: dependencies: any-promise: 1.3.0 + timeago.js@4.0.2: {} + tiny-glob@0.2.9: dependencies: globalyzer: 0.1.0 diff --git a/src/app.html b/src/app.html index 07a5bdaf..01eccc4e 100644 --- a/src/app.html +++ b/src/app.html @@ -4,6 +4,7 @@ + %sveltekit.head% diff --git a/src/hooks.ts b/src/hooks.ts index 2b5f2044..a0d22fac 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -10,6 +10,11 @@ export const reroute: Reroute = ({ url }) => { return url.pathname; } + // If this is a lemonsqueezy webhook. + if (url.host == env.PUBLIC_LEMONSQUEEZY_WEBHOOK_DOMAIN) { + return '/__internal__/lemonsqueezy-webhook'; + } + // If the host is our traefik config host if (url.host == env.PUBLIC_TRAEFIK_CONFIG_HOST) { // If it is a request to the root diff --git a/src/lib/billing.ts b/src/lib/billing.ts new file mode 100644 index 00000000..56cda68a --- /dev/null +++ b/src/lib/billing.ts @@ -0,0 +1,173 @@ +import { env } from '$env/dynamic/private'; +import { env as pubenv } from '$env/dynamic/public'; +import * as lemon from '@lemonsqueezy/lemonsqueezy.js'; +import { redis } from './redis'; +import { usernames } from './usernames'; + +const REDIS_PREFIX = 'weird:billing:lemon:'; +const REDIS_SUBSCRIPTIONS_PREFIX = REDIS_PREFIX + 'subscriptions:'; + +type WebhookEventKind = + | 'order_created' + | 'order_refunded' + | 'subscription_created' + | 'subscription_updated' + | 'subscription_cancelled' + | 'subscription_resumed' + | 'subscription_expired' + | 'subscription_paused' + | 'subscription_unpaused' + | 'subscription_payment_success' + | 'subscription_payment_failed' + | 'subscription_payment_recovered' + | 'subscription_payment_refunded' + | 'license_key_created' + | 'license_key_updated'; + +type WebhookPayloadKind = { + meta: { + event_name: Kind; + custom_data?: Record; + }; + data: Data['data']; +}; +type WebhookPayload = + | WebhookPayloadKind<'order_created', lemon.Order> + | WebhookPayloadKind<'order_refunded', lemon.Order> + | WebhookPayloadKind<'subscription_created', lemon.Subscription> + | WebhookPayloadKind<'subscription_updated', lemon.Subscription> + | WebhookPayloadKind<'subscription_cancelled', lemon.Subscription> + | WebhookPayloadKind<'subscription_resumed', lemon.Subscription> + | WebhookPayloadKind<'subscription_expired', lemon.Subscription> + | WebhookPayloadKind<'subscription_paused', lemon.Subscription> + | WebhookPayloadKind<'subscription_unpaused', lemon.Subscription> + | WebhookPayloadKind<'subscription_payment_success', lemon.Subscription> + | WebhookPayloadKind<'subscription_payment_failed', lemon.Subscription> + | WebhookPayloadKind<'subscription_payment_recovered', lemon.Subscription> + | WebhookPayloadKind<'subscription_payment_refunded', lemon.Subscription> + | WebhookPayloadKind<'license_key_created', lemon.LicenseKey> + | WebhookPayloadKind<'license_key_updated', lemon.LicenseKey>; + +export type SubscriptionInfo = { + id: string; + attributes: Omit; +}; + +class BillingEngine { + constructor() { + lemon.lemonSqueezySetup({ + apiKey: env.LEMONSQUEEZY_API_KEY, + onError: (e) => { + console.error('LemonSqueezy.js error:', e); + } + }); + } + + async getWeirdNerdCheckoutLink(userEmail: string, rauthyId: string): Promise { + const checkout = await lemon.createCheckout( + env.LEMONSQUEEZY_STORE_ID, + env.LEMONSQUEEZY_WEIRD_NERD_VARIANT_ID, + { + checkoutOptions: { embed: true }, + checkoutData: { + email: userEmail, + discountCode: 'WEIRD1', + custom: { + rauthyId + } + }, + productOptions: { + redirectUrl: pubenv.PUBLIC_URL + '/my-profile' + } + } + ); + + if (checkout.data) { + return checkout.data.data.attributes.url; + } else { + throw `Error creating checkout link: ${checkout.error}`; + } + } + + async getWeirdNerdSubscriptionInfo(rauthyId: string): Promise { + const subscriptions: SubscriptionInfo[] = []; + + const prefix = REDIS_SUBSCRIPTIONS_PREFIX + rauthyId + ':'; + for await (const key of redis.scanIterator({ + MATCH: prefix + '*' + })) { + const s = await redis.get(key); + if (!s) throw `Subscription not found at ${key} in redis.`; + subscriptions.push(JSON.parse(s)); + } + + return subscriptions; + } + + async getBillingMethodUpdateLink(rauthyId: string): Promise { + // Check for subscriptions for this user + const subscriptions = await this.getWeirdNerdSubscriptionInfo(rauthyId); + if (subscriptions.length > 0) { + // Get the subscription ID + let subscriptionId = subscriptions[0].id; + // Get an up-to-date reference to the subscription, which will include a signed customer + // portal URL. + const upToDateSubscription = await lemon.getSubscription(subscriptionId); + console.log(JSON.stringify(upToDateSubscription, null, ' ')); + if (upToDateSubscription.data) { + return upToDateSubscription.data.data.attributes.urls.update_payment_method; + } + } + } + + async cancelBillingSubscription(rauthyId: string) { + const subscriptions = await this.getWeirdNerdSubscriptionInfo(rauthyId); + const activeSubscriptions = subscriptions.filter((x) => x.attributes.status == 'active'); + if (activeSubscriptions.length != 1) { + throw 'More than one active subscription, not sure how to cancel.'; + } + const resp = await lemon.cancelSubscription(activeSubscriptions[0].id); + if (resp.error) { + console.error(`Error cancelling lemonsqueezy subscription: ${resp.error}`); + } + } + + async resumeBillingSubscription(rauthyId: string) { + const subscriptions = await this.getWeirdNerdSubscriptionInfo(rauthyId); + const cancelledSubscriptions = subscriptions.filter((x) => x.attributes.status == 'cancelled'); + if (cancelledSubscriptions.length != 1) { + throw 'More than one cancelled subscription, not sure how to cancel.'; + } + const resp = await lemon.updateSubscription(cancelledSubscriptions[0].id, { cancelled: false }); + if (resp.error) { + console.error(`Error resuming lemonsqueezy subscription: ${resp.error}`); + } + } + + async #updateSubscriptionInfo(rauthyId: string, subscription: lemon.Subscription['data']) { + if (subscription.type == 'subscriptions') { + const info: SubscriptionInfo = { + id: subscription.id, + attributes: { ...subscription.attributes, ...{ urls: undefined } } + }; + + await redis.set( + REDIS_SUBSCRIPTIONS_PREFIX + rauthyId + ':' + subscription.id, + JSON.stringify(info) + ); + } + } + + async handleWebhook(webhook: WebhookPayload) { + if (webhook.meta.event_name.startsWith('subscription_')) { + // WARNING: this is unintuitive, but apparently lemonsqueezy converts our custom + // metadata to snake case. Watch out! + const rauthyId = webhook.meta.custom_data?.['rauthy_id']; + const data = webhook.data as lemon.Subscription['data']; + if (!rauthyId) throw 'rauthyId metadata missing from webhook.'; + await this.#updateSubscriptionInfo(rauthyId, data); + } + } +} + +export const billing = new BillingEngine(); diff --git a/src/routes/(app)/[username]/+layout.server.ts b/src/routes/(app)/[username]/+layout.server.ts index 28bc953e..e9f9fa8e 100644 --- a/src/routes/(app)/[username]/+layout.server.ts +++ b/src/routes/(app)/[username]/+layout.server.ts @@ -1,4 +1,4 @@ -import { appendSubpath, getProfile, listChildren, profileLinkByUsername } from '$lib/leaf/profile'; +import { getProfile, listChildren } from '$lib/leaf/profile'; import { error, redirect } from '@sveltejs/kit'; import type { LayoutServerLoad } from '../$types'; import { getSession } from '$lib/rauthy/server'; @@ -7,6 +7,7 @@ import { Name } from 'leaf-proto/components'; import { env } from '$env/dynamic/public'; import { usernames } from '$lib/usernames/index'; import { base32Encode } from 'leaf-proto'; +import { billing } from '$lib/billing'; export const load: LayoutServerLoad = async ({ fetch, params, request }) => { if (params.username?.endsWith('.' + env.PUBLIC_USER_DOMAIN_PARENT)) { @@ -41,11 +42,15 @@ export const load: LayoutServerLoad = async ({ fetch, params, request }) => { ) ).filter((x) => x) as { slug: string; name?: string }[]; + const subscriptionInfo = + sessionInfo && (await billing.getWeirdNerdSubscriptionInfo(sessionInfo.user_id)); + return { profile, profileMatchesUserSession, pages, username: fullUsername, - subspace: base32Encode(subspace) + subspace: base32Encode(subspace), + subscriptionInfo }; }; diff --git a/src/routes/(app)/[username]/+layout.svelte b/src/routes/(app)/[username]/+layout.svelte index a231b90a..d4a99106 100644 --- a/src/routes/(app)/[username]/+layout.svelte +++ b/src/routes/(app)/[username]/+layout.svelte @@ -1,10 +1,12 @@
@@ -53,9 +67,19 @@

Settings

- +
+ {#if env.PUBLIC_ENABLE_EXPERIMENTS == 'true'} + + {/if} + +
{/if} diff --git a/src/routes/(app)/[username]/components/ManageSubscriptionModal.svelte b/src/routes/(app)/[username]/components/ManageSubscriptionModal.svelte new file mode 100644 index 00000000..e7560c39 --- /dev/null +++ b/src/routes/(app)/[username]/components/ManageSubscriptionModal.svelte @@ -0,0 +1,107 @@ + + +{#if $modalStore[0]} +
+
+
Manage Subscription
+ +
+ {#if !hasNonExpired} +
+
Weird Nerd
+
Unlock memorable usernames or custom domains!
+
+ +
+
+ {:else} + {#each subscriptionInfo as subscription} +
+
+ {subscription.attributes.product_name} +
+
+
+ Status: + {subscription.attributes.status_formatted} +
+ {#if subscription.attributes.status == 'cancelled' && subscription.attributes.ends_at} + Subscription Ends: + {format(subscription.attributes.ends_at)} + {/if} + {#if subscription.attributes.status == 'active' && subscription.attributes.renews_at} + Renews Automatically: + {format(subscription.attributes.renews_at)} + {/if} +
+
+ + {#if subscription.attributes.status == 'active'} + + {:else if subscription.attributes.status == 'cancelled'} + + {/if} +
+
+ {/each} + {/if} +
+
+ + +
+{/if} diff --git a/src/routes/(app)/[username]/settings/cancelBillingSubscription/+server.ts b/src/routes/(app)/[username]/settings/cancelBillingSubscription/+server.ts new file mode 100644 index 00000000..4dc5eb30 --- /dev/null +++ b/src/routes/(app)/[username]/settings/cancelBillingSubscription/+server.ts @@ -0,0 +1,11 @@ +import { billing } from '$lib/billing'; +import { getUserInfo } from '$lib/rauthy/server'; +import { type RequestHandler, error } from '@sveltejs/kit'; + +export const POST: RequestHandler = async ({ fetch, request }) => { + const { userInfo } = await getUserInfo(fetch, request); + if (!userInfo) return error(404, 'Unauthorized'); + + await billing.cancelBillingSubscription(userInfo.id); + return new Response(); +}; diff --git a/src/routes/(app)/[username]/settings/getBillingCheckoutLink/+server.ts b/src/routes/(app)/[username]/settings/getBillingCheckoutLink/+server.ts new file mode 100644 index 00000000..98358b2a --- /dev/null +++ b/src/routes/(app)/[username]/settings/getBillingCheckoutLink/+server.ts @@ -0,0 +1,10 @@ +import { billing } from '$lib/billing'; +import { getUserInfo } from '$lib/rauthy/server'; +import { type RequestHandler, error } from '@sveltejs/kit'; + +export const POST: RequestHandler = async ({ fetch, request }) => { + const { userInfo } = await getUserInfo(fetch, request); + if (!userInfo) return error(404, 'Unauthorized'); + const checkoutUrl = await billing.getWeirdNerdCheckoutLink(userInfo.email, userInfo.id); + return new Response(checkoutUrl); +}; diff --git a/src/routes/(app)/[username]/settings/getBillingMethodUpdateLink/+server.ts b/src/routes/(app)/[username]/settings/getBillingMethodUpdateLink/+server.ts new file mode 100644 index 00000000..921d36c7 --- /dev/null +++ b/src/routes/(app)/[username]/settings/getBillingMethodUpdateLink/+server.ts @@ -0,0 +1,11 @@ +import { billing } from '$lib/billing'; +import { getUserInfo } from '$lib/rauthy/server'; +import { type RequestHandler, error } from '@sveltejs/kit'; + +export const POST: RequestHandler = async ({ fetch, request }) => { + const { userInfo } = await getUserInfo(fetch, request); + if (!userInfo) return error(404, 'Unauthorized'); + + const checkoutUrl = await billing.getBillingMethodUpdateLink(userInfo.id); + return new Response(checkoutUrl); +}; diff --git a/src/routes/(app)/[username]/settings/resumeBillingSubscription/+server.ts b/src/routes/(app)/[username]/settings/resumeBillingSubscription/+server.ts new file mode 100644 index 00000000..29203446 --- /dev/null +++ b/src/routes/(app)/[username]/settings/resumeBillingSubscription/+server.ts @@ -0,0 +1,11 @@ +import { billing } from '$lib/billing'; +import { getUserInfo } from '$lib/rauthy/server'; +import { type RequestHandler, error } from '@sveltejs/kit'; + +export const POST: RequestHandler = async ({ fetch, request }) => { + const { userInfo } = await getUserInfo(fetch, request); + if (!userInfo) return error(404, 'Unauthorized'); + + await billing.resumeBillingSubscription(userInfo.id); + return new Response(); +}; diff --git a/src/routes/(internal)/__internal__/admin/+page.svelte b/src/routes/(internal)/__internal__/admin/+page.svelte index 2df483b2..5983e681 100644 --- a/src/routes/(internal)/__internal__/admin/+page.svelte +++ b/src/routes/(internal)/__internal__/admin/+page.svelte @@ -1,10 +1,8 @@ diff --git a/src/routes/(internal)/__internal__/admin/dns/set/+page.server.ts b/src/routes/(internal)/__internal__/admin/dns/set/+page.server.ts deleted file mode 100644 index 1c0598da..00000000 --- a/src/routes/(internal)/__internal__/admin/dns/set/+page.server.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { getSession } from '$lib/rauthy/server'; -import { error, type Actions } from '@sveltejs/kit'; - -export const actions = { - default: async ({ fetch, request }) => { - let { sessionInfo } = await getSession(fetch, request); - if (!sessionInfo?.roles?.includes('admin')) { - return error(403, 'Access denied'); - } - let formData; - let domain: string; - let valuesStr: string; - let kind: string; - try { - formData = await request.formData(); - domain = formData.get('domain')?.toString() || ''; - valuesStr = formData.get('values')?.toString() || ''; - kind = formData.get('kind')?.toString() || 'TXT'; - } catch (e) { - return { error: JSON.stringify(e) }; - } - - try { - throw 'TODO: work in progress'; - } catch (e) { - return { error: JSON.stringify(e) }; - } - } -} satisfies Actions; diff --git a/src/routes/(internal)/__internal__/admin/dns/set/+page.svelte b/src/routes/(internal)/__internal__/admin/dns/set/+page.svelte deleted file mode 100644 index 2c560640..00000000 --- a/src/routes/(internal)/__internal__/admin/dns/set/+page.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - -

Debugging tool to set DNS records on the Weird server.

- - - -{#if form?.error} -
- {form.error} -
-{/if} - -
- - - - - -
diff --git a/src/routes/(internal)/__internal__/lemonsqueezy-webhook/+server.ts b/src/routes/(internal)/__internal__/lemonsqueezy-webhook/+server.ts new file mode 100644 index 00000000..72dc5e22 --- /dev/null +++ b/src/routes/(internal)/__internal__/lemonsqueezy-webhook/+server.ts @@ -0,0 +1,33 @@ +import { env } from '$env/dynamic/private'; +import { billing } from '$lib/billing'; +import type { RequestHandler } from '@sveltejs/kit'; +import crypto from 'node:crypto'; + +function validateHmac(secret: string, body: Uint8Array, sig?: string) { + const hmac = crypto.createHmac('sha256', secret); + const digest = Buffer.from(hmac.update(body).digest('hex'), 'utf8'); + const signature = Buffer.from(sig || '', 'utf8'); + + if (!crypto.timingSafeEqual(digest, signature)) { + throw new Error('Invalid signature.'); + } +} + +export const POST: RequestHandler = async ({ request }) => { + try { + // Validate webhook signature + const body = new Uint8Array(await request.arrayBuffer()); + validateHmac( + env.LEMONSQUEEZY_WEBHOOK_SECRET, + body, + request.headers.get('X-Signature') || undefined + ); + const text = new TextDecoder().decode(body); + + await billing.handleWebhook(JSON.parse(text)); + } catch (e) { + console.error('Error handling lemonsqueezy webhook', e); + return new Response(null, { status: 500 }); + } + return new Response(null, { status: 200 }); +}; diff --git a/src/shims.d.ts b/src/shims.d.ts new file mode 100644 index 00000000..68c3184a --- /dev/null +++ b/src/shims.d.ts @@ -0,0 +1,39 @@ +interface Window { + createLemonSqueezy: () => void; + LemonSqueezy: { + /** + * Initialises Lemon.js on your page. + * @param options - An object with a single property, eventHandler, which is a function that will be called when Lemon.js emits an event. + */ + Setup: (options: { eventHandler: (event: { event: string }) => void }) => void; + /** + * Refreshes `lemonsqueezy-button` listeners on the page. + */ + Refresh: () => void; + + Url: { + /** + * Opens a given Lemon Squeezy URL, typically these are Checkout or Payment Details Update overlays. + * @param url - The URL to open. + */ + Open: (url: string) => void; + + /** + * Closes the current opened Lemon Squeezy overlay checkout window. + */ + Close: () => void; + }; + Affiliate: { + /** + * Retrieve the affiliate tracking ID + */ + GetID: () => string; + + /** + * Append the affiliate tracking parameter to the given URL + * @param url - The URL to append the affiliate tracking parameter to. + */ + Build: (url: string) => string; + }; + }; +}