Skip to content

Commit

Permalink
feat: implement minimal billing integration with LemonSqueezy.
Browse files Browse the repository at this point in the history
  • Loading branch information
zicklag committed Dec 5, 2024
1 parent b7cdbd6 commit 448100f
Show file tree
Hide file tree
Showing 19 changed files with 467 additions and 67 deletions.
12 changes: 11 additions & 1 deletion .env.local
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,14 @@ GH_CLIENT_SECRET=

# Bsky
BSKY_IDENTIFIER=
BSKY_PSWD=
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
2 changes: 1 addition & 1 deletion .typos.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://app.lemonsqueezy.com/js/lemon.js" defer></script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" data-theme="weird">
Expand Down
5 changes: 5 additions & 0 deletions src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
173 changes: 173 additions & 0 deletions src/lib/billing.ts
Original file line number Diff line number Diff line change
@@ -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<Kind extends WebhookEventKind, Data extends { data: any }> = {
meta: {
event_name: Kind;
custom_data?: Record<string, string>;
};
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<lemon.Subscription['data']['attributes'], 'urls'>;
};

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<string> {
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<SubscriptionInfo[]> {
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<string | undefined> {
// 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();
9 changes: 7 additions & 2 deletions src/routes/(app)/[username]/+layout.server.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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)) {
Expand Down Expand Up @@ -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
};
};
32 changes: 28 additions & 4 deletions src/routes/(app)/[username]/+layout.svelte
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { type Snippet } from 'svelte';
import { page } from '$app/stores';
import type { PageData } from './$types';
import Icon from '@iconify/svelte';
import { getModalStore, type ModalSettings } from '@skeletonlabs/skeleton';
import SetHandleModal from './components/ChangeHandleModal.svelte';
import ManageSubscriptionModal from './components/ManageSubscriptionModal.svelte';
import { env } from '$env/dynamic/public';
const { data, children }: { children: Snippet; data: PageData } = $props();
Expand All @@ -23,6 +25,18 @@
}
}
});
const manageSubscriptionModal: ModalSettings = $derived({
type: 'component',
component: { ref: ManageSubscriptionModal },
subscriptionInfo: data.subscriptionInfo,
async response(r) {
if ('error' in r) {
error = r.error;
} else {
error = null;
}
}
});
</script>

<div class="flex flex-row flex-wrap-reverse sm:flex-nowrap">
Expand Down Expand Up @@ -53,9 +67,19 @@
<div class="flex-grow"></div>

<h2 class="mb-2 text-lg font-bold">Settings</h2>
<button class="variant-ghost btn" onclick={() => modalStore.trigger(setHandleModal)}>
Change Handle
</button>
<div class="flex flex-col gap-2">
{#if env.PUBLIC_ENABLE_EXPERIMENTS == 'true'}
<button
class="variant-ghost btn"
onclick={() => modalStore.trigger(manageSubscriptionModal)}
>
Manage Subscription
</button>
{/if}
<button class="variant-ghost btn" onclick={() => modalStore.trigger(setHandleModal)}>
Change Handle
</button>
</div>
</aside>
{/if}

Expand Down
Loading

0 comments on commit 448100f

Please sign in to comment.