From 5acfdd7601c6743575131f5ec6fb41939eb928b8 Mon Sep 17 00:00:00 2001 From: adityaoberai Date: Sat, 10 Oct 2026 02:32:58 +0530 Subject: [PATCH] feat: verify Stripe and Lemon Squeezy purchases on both webhook and redirect Webhooks now fetch the order or subscription from the provider instead of trusting the event payload. Checkout returns to a new GET /success route that runs the same check and provisioning before redirecting to successUrl, so either path can provision the user. Deterministic order document IDs and idempotent label updates make concurrent runs safe. Co-Authored-By: Claude Opus 5.5 --- node/payments-with-lemon-squeezy/README.md | 8 +- .../src/appwrite.js | 35 ++++-- .../src/lemonsqueezy.js | 89 ++++++++++++++- node/payments-with-lemon-squeezy/src/main.js | 96 +++++++++++++--- .../static/index.html | 1 + node/payments-with-stripe/README.md | 9 +- node/payments-with-stripe/src/appwrite.js | 37 +++--- node/payments-with-stripe/src/main.js | 80 ++++++++++--- node/payments-with-stripe/src/stripe.js | 18 ++- .../README.md | 8 +- .../src/appwrite.js | 16 +-- .../src/lemonsqueezy.js | 87 +++++++++++++- .../src/main.js | 106 +++++++++++++++--- .../static/index.html | 1 + node/subscriptions-with-stripe/README.md | 9 +- .../subscriptions-with-stripe/src/appwrite.js | 16 +-- node/subscriptions-with-stripe/src/main.js | 86 ++++++++++---- node/subscriptions-with-stripe/src/stripe.js | 34 +++++- 18 files changed, 623 insertions(+), 113 deletions(-) diff --git a/node/payments-with-lemon-squeezy/README.md b/node/payments-with-lemon-squeezy/README.md index 8f0796c1..5dc1eab8 100644 --- a/node/payments-with-lemon-squeezy/README.md +++ b/node/payments-with-lemon-squeezy/README.md @@ -30,9 +30,15 @@ Location: https://ap...re.lemonsqueezy.com/checkout/custom/7576abf3-...e2fb Location: https://mywebapp.com/cancel ``` +### `GET /success` + +Lemon Squeezy redirects the user here after checkout. Lemon Squeezy doesn't pass an order ID in the redirect, so the checkout's redirect URL carries a `state` parameter signed with `LEMON_SQUEEZY_WEBHOOK_SECRET`. It holds the user ID, the email entered at checkout and the checkout time. This endpoint looks up purchases made with that email since the checkout started. If no email was entered, it leaves provisioning to the webhook. It provisions the user the same way the webhook does, then redirects to the `successUrl` given to the checkout endpoint. + +The webhook and this endpoint both provision the user, so either one works if the other fails. Running twice is safe: orders use the provider ID as the row ID, so the database rejects a second write. + ### `POST /webhook` -This endpoint is a webhook that handles Lemon Squeezy event `order_created`. It validates the incoming request using the `X-Signature` header from the Lemon Squeezy webhook. If the validation fails, a `401` response is sent. +This endpoint is a webhook that handles Lemon Squeezy event `order_created`. It validates the incoming request using the `X-Signature` header from the Lemon Squeezy webhook. If the validation fails, a `401` response is sent. The webhook payload isn't trusted: the function fetches the order from Lemon Squeezy and stores it only if its status is `paid`. **Parameters** diff --git a/node/payments-with-lemon-squeezy/src/appwrite.js b/node/payments-with-lemon-squeezy/src/appwrite.js index 1ae7cbcf..f6099790 100644 --- a/node/payments-with-lemon-squeezy/src/appwrite.js +++ b/node/payments-with-lemon-squeezy/src/appwrite.js @@ -1,4 +1,4 @@ -import { Client, Databases, ID, Permission, Role } from 'node-appwrite'; +import { Client, Databases, Permission, Role } from 'node-appwrite'; class AppwriteService { constructor(apiKey) { @@ -9,8 +9,6 @@ class AppwriteService { .setKey(apiKey); this.databases = new Databases(client); - - this.setup(); } async setup() { @@ -78,17 +76,30 @@ class AppwriteService { } } + /** + * Stores a paid order. The webhook and the /success redirect can both run + * for the same order, possibly at the same time. Using the order ID as the + * document ID makes the database reject the second write with a 409, so the + * order is only ever stored once. + * @returns {Promise} false if the order was already stored + */ async createOrder(databaseId, collectionId, userId, orderId) { - await this.databases.createDocument( - databaseId, - collectionId, - ID.unique(), - { - userId, + try { + await this.databases.createDocument( + databaseId, + collectionId, orderId, - }, - [Permission.read(Role.user(userId))] - ); + { + userId, + orderId, + }, + [Permission.read(Role.user(userId))] + ); + return true; + } catch (err) { + if (err.code !== 409) throw err; + return false; + } } } diff --git a/node/payments-with-lemon-squeezy/src/lemonsqueezy.js b/node/payments-with-lemon-squeezy/src/lemonsqueezy.js index c419b62a..67f68117 100644 --- a/node/payments-with-lemon-squeezy/src/lemonsqueezy.js +++ b/node/payments-with-lemon-squeezy/src/lemonsqueezy.js @@ -1,6 +1,8 @@ import { lemonSqueezySetup, createCheckout, + getOrder, + listOrders, } from '@lemonsqueezy/lemonsqueezy.js'; import crypto from 'crypto'; @@ -11,12 +13,21 @@ class LemonSqueezyService { }); } - async createCheckout(context, userId, userEmail, userName) { + async createCheckout(context, userId, userEmail, userName, verifyUrl, successUrl) { try { const storeId = process.env.LEMON_SQUEEZY_STORE_ID; const variantId = process.env.LEMON_SQUEEZY_VARIANT_ID; const newCheckout = { productOptions: { + // Lemon Squeezy returns the user to this function first, which + // verifies the purchase and then redirects to successUrl. + redirectUrl: `${verifyUrl}?state=${this.signState({ + userId, + // Only an email the user entered identifies their purchase. + email: userEmail, + successUrl, + createdAt: new Date().toISOString(), + })}`, name: 'Test Product', description: 'A product created to test Lemon Squeezy payments in Appwrite Functions.', @@ -44,6 +55,82 @@ class LemonSqueezyService { } } + /** + * Fetches an order from Lemon Squeezy, so the webhook payload doesn't have + * to be trusted + * @param {string} orderId + */ + async getOrder(orderId) { + const { data, error } = await getOrder(orderId); + if (error) throw error; + return data.data; + } + + /** + * Finds the paid orders for the configured variant that were placed with + * the given email after the given time. Lemon Squeezy doesn't send an order + * ID to the redirect URL, so this is how /success finds the purchase. + * @param {string} email + * @param {string} since ISO 8601 timestamp + */ + async findPaidOrders(email, since) { + const { data, error } = await listOrders({ + filter: { + storeId: process.env.LEMON_SQUEEZY_STORE_ID, + userEmail: email, + }, + }); + if (error) throw error; + + return data.data.filter( + (order) => + order.attributes.status === 'paid' && + String(order.attributes.first_order_item.variant_id) === + String(process.env.LEMON_SQUEEZY_VARIANT_ID) && + new Date(order.attributes.created_at) >= new Date(since) + ); + } + + /** + * Signs the redirect state, so /success can trust the user ID in it + * @param {Record} state + * @returns {string} + */ + signState(state) { + const payload = Buffer.from(JSON.stringify(state)).toString('base64url'); + const signature = crypto + .createHmac('sha256', process.env.LEMON_SQUEEZY_WEBHOOK_SECRET) + .update(payload) + .digest('base64url'); + return `${payload}.${signature}`; + } + + /** + * @param {string} token + * @returns {Record | null} The state, or null if + * the signature is invalid + */ + verifyState(token) { + const [payload, signature] = String(token ?? '').split('.'); + if (!payload || !signature) return null; + + const expected = Buffer.from( + crypto + .createHmac('sha256', process.env.LEMON_SQUEEZY_WEBHOOK_SECRET) + .update(payload) + .digest('base64url') + ); + const actual = Buffer.from(signature); + if ( + expected.length !== actual.length || + !crypto.timingSafeEqual(expected, actual) + ) { + return null; + } + + return JSON.parse(Buffer.from(payload, 'base64url').toString()); + } + validateWebhook(context) { try { const secret = process.env.LEMON_SQUEEZY_WEBHOOK_SECRET; diff --git a/node/payments-with-lemon-squeezy/src/main.js b/node/payments-with-lemon-squeezy/src/main.js index d17466d4..56e9d9ab 100644 --- a/node/payments-with-lemon-squeezy/src/main.js +++ b/node/payments-with-lemon-squeezy/src/main.js @@ -15,6 +15,63 @@ export default async (context) => { const databaseId = process.env.APPWRITE_DATABASE_ID ?? 'orders'; const collectionId = process.env.APPWRITE_COLLECTION_ID ?? 'orders'; + const appwrite = new AppwriteService(context.req.headers['x-appwrite-key']); + const lemonsqueezy = new LemonSqueezyService(); + + /** + * Stores the order if it's paid. Both the webhook and the /success redirect + * call this, so the order is stored even if one of them never arrives. + * @param {*} order Order fetched from the Lemon Squeezy API + * @param {string} userId + */ + async function fulfillOrder(order, userId) { + if (order.attributes.status !== 'paid') { + log(`Order ${order.id} is ${order.attributes.status}, not fulfilling.`); + return; + } + + await appwrite.setup(); + + const created = await appwrite.createOrder( + databaseId, + collectionId, + userId, + String(order.id) + ); + log( + created + ? `Created order document for user ${userId} with Lemon Squeezy order ID ${order.id}` + : `Order ${order.id} was already stored.` + ); + } + + if (req.method === 'GET' && req.path === '/success') { + const state = lemonsqueezy.verifyState(req.query.state); + if (!state) { + error('Invalid redirect state.'); + return res.redirect('/', 303); + } + + try { + // Without an email the user entered, the order can't be matched to them + // here, so it's left to the webhook. + if (state.email) { + const orders = await lemonsqueezy.findPaidOrders( + state.email, + state.createdAt + ); + for (const order of orders) { + await fulfillOrder(order, state.userId); + } + } + } catch (err) { + // The webhook still fulfills the order, so send the user on regardless. + error(err); + } + + return res.redirect(state.successUrl || '/', 303); + } + if (req.method === 'GET') { const html = interpolate(getStaticFile('index.html'), { APPWRITE_FUNCTION_API_ENDPOINT: process.env.APPWRITE_FUNCTION_API_ENDPOINT, @@ -27,13 +84,15 @@ export default async (context) => { return res.text(html, 200, { 'Content-Type': 'text/html; charset=utf-8' }); } - const appwrite = new AppwriteService(context.req.headers['x-appwrite-key']); - const lemonsqueezy = new LemonSqueezyService(); - switch (req.path) { case '/checkout': const fallbackUrl = req.scheme + '://' + req.headers['host'] + '/'; + const successUrl = req.body?.successUrl ?? fallbackUrl; const failureUrl = req.body?.failureUrl ?? fallbackUrl; + // Lemon Squeezy returns the user to this function first, which verifies + // the order and then redirects to successUrl. + const verifyUrl = + req.body?.verifyUrl ?? new URL('/success', successUrl).toString(); const userId = req.headers['x-appwrite-user-id']; let userEmail = req.body?.email; @@ -48,7 +107,9 @@ export default async (context) => { context, userId, userEmail, - userName + userName, + verifyUrl, + successUrl ); if (!checkout) { error('Failed to create Lemon Squeezy checkout.'); @@ -67,22 +128,21 @@ export default async (context) => { return res.json({ success: false }, 401); } - const order = req.body; - - log(order); + const eventType = req.headers['x-event-name']; + if (eventType !== 'order_created') { + log(`Ignored Lemon Squeezy event ${eventType}.`); + return res.json({ success: true }); + } - const orderUserId = order.meta.custom_data.user_id; - const orderId = order.data.id; + const orderUserId = req.body.meta.custom_data?.user_id; + if (!orderUserId) { + error(`Order ${req.body.data.id} has no user_id in its custom data.`); + return res.json({ success: true }); + } - await appwrite.createOrder( - databaseId, - collectionId, - orderUserId, - orderId - ); - log( - `Created order document for user ${orderUserId} with Lemon Squeezy order ID ${orderId}` - ); + // Look the order up instead of trusting the event payload. + const order = await lemonsqueezy.getOrder(req.body.data.id); + await fulfillOrder(order, orderUserId); return res.json({ success: true }); diff --git a/node/payments-with-lemon-squeezy/static/index.html b/node/payments-with-lemon-squeezy/static/index.html index 3ef0baab..592ac7ba 100644 --- a/node/payments-with-lemon-squeezy/static/index.html +++ b/node/payments-with-lemon-squeezy/static/index.html @@ -160,6 +160,7 @@
'{{APPWRITE_FUNCTION_ID}}', JSON.stringify({ failureUrl: window.location.href, + successUrl: window.location.href, email, name, }), diff --git a/node/payments-with-stripe/README.md b/node/payments-with-stripe/README.md index a98659af..fe64874b 100644 --- a/node/payments-with-stripe/README.md +++ b/node/payments-with-stripe/README.md @@ -15,6 +15,7 @@ This endpoint initiates a Stripe checkout session. The user ID is fetched from t | x-appwrite-user-id | User ID from Appwrite. | Header | String | 642...7cd | | Content-Type | The content type of the request body | Header | `application/json` | N/A | | successUrl | The URL to redirect to after a successful payment. | Body | String | https://example.com/success | +| verifyUrl | Where the provider returns the user to verify the purchase. Defaults to `/success` on the `successUrl` origin. | Body | String | https:///success | | failureUrl | The URL to redirect to after a cancelled payment attempt. | Body | String | https://example.com/failure | **Response** @@ -31,9 +32,15 @@ Location: https://checkout.stripe.com/pay/cs_test_...#fidkdWxOYHwnP Location: https://mywebapp.com/cancel ``` +### `GET /success` + +Stripe redirects the user here after checkout. Stripe adds `session_id` to the redirect URL, and this endpoint fetches the checkout session from Stripe with it. It provisions the user the same way the webhook does, then redirects to the `successUrl` given to the checkout endpoint. + +The webhook and this endpoint both provision the user, so either one works if the other fails. Running twice is safe: orders use the provider ID as the row ID, so the database rejects a second write. + ### `POST /webhook` -This endpoint is a webhook that handles Stripe event `checkout.session.completed`. It validates the incoming request using the Stripe's validateWebhook method. If the validation fails, a `401` response is sent. +This endpoint is a webhook that handles Stripe events `checkout.session.completed` and `checkout.session.async_payment_succeeded`. It validates the incoming request using the Stripe's validateWebhook method. If the validation fails, a `401` response is sent. The webhook payload isn't trusted: the function fetches the checkout session from Stripe and stores the order only if its `payment_status` is `paid`. **Parameters** diff --git a/node/payments-with-stripe/src/appwrite.js b/node/payments-with-stripe/src/appwrite.js index 0e3593a7..b97eb98a 100644 --- a/node/payments-with-stripe/src/appwrite.js +++ b/node/payments-with-stripe/src/appwrite.js @@ -1,4 +1,4 @@ -import { Client, Databases, ID, Permission, Role } from 'node-appwrite'; +import { Client, Databases, Permission, Role } from 'node-appwrite'; class AppwriteService { constructor(apiKey) { @@ -12,23 +12,34 @@ class AppwriteService { } /** + * Stores a paid order. The webhook and the /success redirect can both run + * for the same payment, possibly at the same time. Using the payment's ID as + * the document ID makes the database reject the second write with a 409, so + * the order is only ever stored once. * @param {string} databaseId * @param {string} collectionId + * @param {string} documentId Stripe payment intent ID * @param {string} userId * @param {string} orderId - * @returns {Promise} + * @returns {Promise} false if the order was already stored */ - async createOrder(databaseId, collectionId, userId, orderId) { - await this.databases.createDocument( - databaseId, - collectionId, - ID.unique(), - { - userId, - orderId, - }, - [Permission.read(Role.user(userId))] - ); + async createOrder(databaseId, collectionId, documentId, userId, orderId) { + try { + await this.databases.createDocument( + databaseId, + collectionId, + documentId, + { + userId, + orderId, + }, + [Permission.read(Role.user(userId))] + ); + return true; + } catch (err) { + if (err.code !== 409) throw err; + return false; + } } /** diff --git a/node/payments-with-stripe/src/main.js b/node/payments-with-stripe/src/main.js index bbff10a2..c8c8e1ae 100644 --- a/node/payments-with-stripe/src/main.js +++ b/node/payments-with-stripe/src/main.js @@ -13,6 +13,63 @@ export default async (context) => { const databaseId = process.env.APPWRITE_DATABASE_ID ?? 'orders'; const collectionId = process.env.APPWRITE_COLLECTION_ID ?? 'orders'; + const appwrite = new AppwriteService(context.req.headers['x-appwrite-key']); + const stripe = new StripeService(); + + /** + * Stores the order if Stripe reports the checkout session as paid. Both the + * webhook and the /success redirect call this, so the order is stored even + * if one of them never arrives. + * @param {string} sessionId + * @returns {Promise} + */ + async function fulfillCheckout(sessionId) { + const session = await stripe.getCheckoutSession(sessionId); + + // Delayed payment methods complete the session before the money arrives. + // checkout.session.async_payment_succeeded fulfills those later. + if (session.payment_status !== 'paid') { + log(`Session ${session.id} is ${session.payment_status}, not fulfilling.`); + return session; + } + + const userId = session.metadata?.userId; + if (!userId) { + error(`Session ${session.id} has no userId in its metadata.`); + return session; + } + + const paymentIntentId = /** @type {string} */ (session.payment_intent); + const created = await appwrite.createOrder( + databaseId, + collectionId, + paymentIntentId, + userId, + session.id + ); + log( + created + ? `Created order document for user ${userId} with Stripe order ID ${session.id}` + : `Order ${session.id} was already stored.` + ); + return session; + } + + if (req.method === 'GET' && req.path === '/success') { + const sessionId = req.query.session_id; + let successUrl = '/'; + + try { + const session = await fulfillCheckout(sessionId); + successUrl = session.metadata?.successUrl || successUrl; + } catch (err) { + // The webhook still fulfills the order, so send the user on regardless. + error(err); + } + + return res.redirect(successUrl, 303); + } + if (req.method === 'GET') { const html = interpolate(getStaticFile('index.html'), { APPWRITE_FUNCTION_API_ENDPOINT: process.env.APPWRITE_FUNCTION_API_ENDPOINT, @@ -25,15 +82,16 @@ export default async (context) => { return res.text(html, 200, { 'Content-Type': 'text/html; charset=utf-8' }); } - const appwrite = new AppwriteService(context.req.headers['x-appwrite-key']); - const stripe = new StripeService(); - switch (req.path) { case '/checkout': const fallbackUrl = req.scheme + '://' + req.headers['host'] + '/'; const successUrl = req.body?.successUrl ?? fallbackUrl; const failureUrl = req.body?.failureUrl ?? fallbackUrl; + // Stripe returns the user to this function first, which verifies the + // payment and then redirects to successUrl. + const verifyUrl = + req.body?.verifyUrl ?? new URL('/success', successUrl).toString(); const userId = req.headers['x-appwrite-user-id']; if (!userId) { @@ -44,6 +102,7 @@ export default async (context) => { const session = await stripe.checkoutPayment( context, userId, + verifyUrl, successUrl, failureUrl ); @@ -67,16 +126,11 @@ export default async (context) => { context.log('Event:'); context.log(event); - if (event.type === 'checkout.session.completed') { - const session = event.data.object; - const userId = session.metadata.userId; - const orderId = session.id; - - await appwrite.createOrder(databaseId, collectionId, userId, orderId); - log( - `Created order document for user ${userId} with Stripe order ID ${orderId}` - ); - return res.json({ success: true }); + if ( + event.type === 'checkout.session.completed' || + event.type === 'checkout.session.async_payment_succeeded' + ) { + await fulfillCheckout(event.data.object.id); } return res.json({ success: true }); diff --git a/node/payments-with-stripe/src/stripe.js b/node/payments-with-stripe/src/stripe.js index d0c7b9e3..ea5becda 100644 --- a/node/payments-with-stripe/src/stripe.js +++ b/node/payments-with-stripe/src/stripe.js @@ -12,10 +12,11 @@ class StripeService { /** * @param {string} userId + * @param {string} verifyUrl The function's /success route * @param {string} successUrl * @param {string} failureUrl */ - async checkoutPayment(context, userId, successUrl, failureUrl) { + async checkoutPayment(context, userId, verifyUrl, successUrl, failureUrl) { /** @type {import('stripe').Stripe.Checkout.SessionCreateParams.LineItem} */ const lineItem = { price_data: { @@ -32,11 +33,14 @@ class StripeService { return await this.client.checkout.sessions.create({ payment_method_types: ['card'], line_items: [lineItem], - success_url: successUrl, + // Stripe fills in {CHECKOUT_SESSION_ID}, so /success knows which + // session to verify before sending the user on to successUrl. + success_url: `${verifyUrl}?session_id={CHECKOUT_SESSION_ID}`, cancel_url: failureUrl, client_reference_id: userId, metadata: { userId, + successUrl, }, mode: 'payment', }); @@ -46,6 +50,16 @@ class StripeService { } } + /** + * Fetches a checkout session from Stripe, so neither the webhook payload + * nor the redirect query string has to be trusted + * @param {string} sessionId + * @returns {Promise} + */ + async getCheckoutSession(sessionId) { + return await this.client.checkout.sessions.retrieve(sessionId); + } + /** * @returns {import("stripe").Stripe.DiscriminatedEvent | null} */ diff --git a/node/subscriptions-with-lemon-squeezy/README.md b/node/subscriptions-with-lemon-squeezy/README.md index 07ac51e8..6706d951 100644 --- a/node/subscriptions-with-lemon-squeezy/README.md +++ b/node/subscriptions-with-lemon-squeezy/README.md @@ -30,9 +30,15 @@ Location: https://ap...re.lemonsqueezy.com/checkout/custom/7576abf3-...e2fb Location: https://mywebapp.com/cancel ``` +### `GET /success` + +Lemon Squeezy redirects the user here after checkout. Lemon Squeezy doesn't pass an order ID in the redirect, so the checkout's redirect URL carries a `state` parameter signed with `LEMON_SQUEEZY_WEBHOOK_SECRET`. It holds the user ID, the email entered at checkout and the checkout time. This endpoint looks up purchases made with that email since the checkout started. If no email was entered, it leaves provisioning to the webhook. It provisions the user the same way the webhook does, then redirects to the `successUrl` given to the checkout endpoint. + +The webhook and this endpoint both provision the user, so either one works if the other fails. Running twice is safe: each run applies the latest status from the provider, and adding or removing a label that is already set or unset does nothing. + ### `POST /webhook` -This endpoint is a webhook that handles two types of events from Lemon Squeezy: `subscription_created` and `subscription_expired`. It validates the incoming request using the `X-Signature` header from the Lemon Squeezy webhook. If the validation fails, a `401` response is sent. +This endpoint is a webhook that handles Lemon Squeezy `subscription_*` lifecycle events. It validates the incoming request using the `X-Signature` header from the Lemon Squeezy webhook. If the validation fails, a `401` response is sent. The webhook payload isn't trusted: the function fetches the subscription from Lemon Squeezy and adds or removes the `subscriber` label based on its current status. **Parameters** diff --git a/node/subscriptions-with-lemon-squeezy/src/appwrite.js b/node/subscriptions-with-lemon-squeezy/src/appwrite.js index fee242b1..59922399 100644 --- a/node/subscriptions-with-lemon-squeezy/src/appwrite.js +++ b/node/subscriptions-with-lemon-squeezy/src/appwrite.js @@ -18,11 +18,13 @@ class AppwriteService { * @returns {Promise} */ async deleteSubscription(userId) { - const labels = (await this.users.get(userId)).labels.filter( - (label) => label !== LabelsSubscriber - ); + const { labels } = await this.users.get(userId); + if (!labels.includes(LabelsSubscriber)) return; - await this.users.updateLabels(userId, labels); + await this.users.updateLabels( + userId, + labels.filter((label) => label !== LabelsSubscriber) + ); } /** @@ -30,10 +32,10 @@ class AppwriteService { * @returns {Promise} */ async createSubscription(userId) { - const labels = (await this.users.get(userId)).labels; - labels.push(LabelsSubscriber); + const { labels } = await this.users.get(userId); + if (labels.includes(LabelsSubscriber)) return; - await this.users.updateLabels(userId, labels); + await this.users.updateLabels(userId, [...labels, LabelsSubscriber]); } } diff --git a/node/subscriptions-with-lemon-squeezy/src/lemonsqueezy.js b/node/subscriptions-with-lemon-squeezy/src/lemonsqueezy.js index 799399c1..4922143e 100644 --- a/node/subscriptions-with-lemon-squeezy/src/lemonsqueezy.js +++ b/node/subscriptions-with-lemon-squeezy/src/lemonsqueezy.js @@ -1,6 +1,8 @@ import { lemonSqueezySetup, createCheckout, + getSubscription, + listSubscriptions, } from '@lemonsqueezy/lemonsqueezy.js'; import crypto from 'crypto'; @@ -11,12 +13,21 @@ class LemonSqueezyService { }); } - async createSubscription(context, userId, userEmail, userName) { + async createSubscription(context, userId, userEmail, userName, verifyUrl, successUrl) { try { const storeId = process.env.LEMON_SQUEEZY_STORE_ID; const variantId = process.env.LEMON_SQUEEZY_VARIANT_ID; const newCheckout = { productOptions: { + // Lemon Squeezy returns the user to this function first, which + // verifies the purchase and then redirects to successUrl. + redirectUrl: `${verifyUrl}?state=${this.signState({ + userId, + // Only an email the user entered identifies their purchase. + email: userEmail, + successUrl, + createdAt: new Date().toISOString(), + })}`, name: 'Test Product', description: 'A product created to test Lemon Squeezy subscriptions in Appwrite Functions.', @@ -44,6 +55,80 @@ class LemonSqueezyService { } } + /** + * Fetches a subscription from Lemon Squeezy, so the webhook payload doesn't + * have to be trusted + * @param {string} subscriptionId + */ + async getSubscription(subscriptionId) { + const { data, error } = await getSubscription(subscriptionId); + if (error) throw error; + return data.data; + } + + /** + * Finds the subscriptions for the configured variant that were created with + * the given email after the given time. Lemon Squeezy doesn't send a + * subscription ID to the redirect URL, so this is how /success finds it. + * @param {string} email + * @param {string} since ISO 8601 timestamp + */ + async findSubscriptions(email, since) { + const { data, error } = await listSubscriptions({ + filter: { + storeId: process.env.LEMON_SQUEEZY_STORE_ID, + variantId: process.env.LEMON_SQUEEZY_VARIANT_ID, + userEmail: email, + }, + }); + if (error) throw error; + + return data.data.filter( + (subscription) => + new Date(subscription.attributes.created_at) >= new Date(since) + ); + } + + /** + * Signs the redirect state, so /success can trust the user ID in it + * @param {Record} state + * @returns {string} + */ + signState(state) { + const payload = Buffer.from(JSON.stringify(state)).toString('base64url'); + const signature = crypto + .createHmac('sha256', process.env.LEMON_SQUEEZY_WEBHOOK_SECRET) + .update(payload) + .digest('base64url'); + return `${payload}.${signature}`; + } + + /** + * @param {string} token + * @returns {Record | null} The state, or null if + * the signature is invalid + */ + verifyState(token) { + const [payload, signature] = String(token ?? '').split('.'); + if (!payload || !signature) return null; + + const expected = Buffer.from( + crypto + .createHmac('sha256', process.env.LEMON_SQUEEZY_WEBHOOK_SECRET) + .update(payload) + .digest('base64url') + ); + const actual = Buffer.from(signature); + if ( + expected.length !== actual.length || + !crypto.timingSafeEqual(expected, actual) + ) { + return null; + } + + return JSON.parse(Buffer.from(payload, 'base64url').toString()); + } + validateWebhook(context) { try { const secret = process.env.LEMON_SQUEEZY_WEBHOOK_SECRET; diff --git a/node/subscriptions-with-lemon-squeezy/src/main.js b/node/subscriptions-with-lemon-squeezy/src/main.js index ba579d57..aa3af3e6 100644 --- a/node/subscriptions-with-lemon-squeezy/src/main.js +++ b/node/subscriptions-with-lemon-squeezy/src/main.js @@ -2,6 +2,21 @@ import AppwriteService from './appwrite.js'; import { getStaticFile, interpolate, throwIfMissing } from './utils.js'; import LemonSqueezyService from './lemonsqueezy.js'; +// A cancelled subscription stays usable until the end of the billing period, +// when it becomes expired. past_due is the grace period while Lemon Squeezy +// retries a failed renewal. Every other status removes access. +const StatusesWithAccess = ['on_trial', 'active', 'past_due', 'cancelled']; + +const SubscriptionEvents = [ + 'subscription_created', + 'subscription_updated', + 'subscription_cancelled', + 'subscription_resumed', + 'subscription_expired', + 'subscription_paused', + 'subscription_unpaused', +]; + export default async (context) => { const { req, res, log, error } = context; @@ -15,6 +30,61 @@ export default async (context) => { const databaseId = process.env.APPWRITE_DATABASE_ID ?? 'orders'; const collectionId = process.env.APPWRITE_COLLECTION_ID ?? 'orders'; + const appwrite = new AppwriteService(context.req.headers['x-appwrite-key']); + const lemonsqueezy = new LemonSqueezyService(); + + /** + * Gives or removes the subscriber label based on the subscription's current + * status. Both the webhook and the /success redirect call this. They can + * run at the same time or out of order, but each one applies the latest + * status from Lemon Squeezy and the label updates are idempotent, so they + * always settle on the same result. + * @param {*} subscription Subscription fetched from the Lemon Squeezy API + * @param {string} userId + */ + async function syncSubscription(subscription, userId) { + const { status } = subscription.attributes; + + if (StatusesWithAccess.includes(status)) { + await appwrite.createSubscription(userId); + log( + `Subscription ${subscription.id} is ${status}, user ${userId} has the subscriber label.` + ); + } else { + await appwrite.deleteSubscription(userId); + log( + `Subscription ${subscription.id} is ${status}, user ${userId} doesn't have the subscriber label.` + ); + } + } + + if (req.method === 'GET' && req.path === '/success') { + const state = lemonsqueezy.verifyState(req.query.state); + if (!state) { + error('Invalid redirect state.'); + return res.redirect('/', 303); + } + + try { + // Without an email the user entered, the subscription can't be matched + // to them here, so it's left to the webhook. + if (state.email) { + const subscriptions = await lemonsqueezy.findSubscriptions( + state.email, + state.createdAt + ); + for (const subscription of subscriptions) { + await syncSubscription(subscription, state.userId); + } + } + } catch (err) { + // The webhook still provisions the user, so send them on regardless. + error(err); + } + + return res.redirect(state.successUrl || '/', 303); + } + if (req.method === 'GET') { const html = interpolate(getStaticFile('index.html'), { APPWRITE_FUNCTION_API_ENDPOINT: process.env.APPWRITE_FUNCTION_API_ENDPOINT, @@ -27,13 +97,15 @@ export default async (context) => { return res.text(html, 200, { 'Content-Type': 'text/html; charset=utf-8' }); } - const appwrite = new AppwriteService(context.req.headers['x-appwrite-key']); - const lemonsqueezy = new LemonSqueezyService(); - switch (req.path) { case '/subscribe': const fallbackUrl = req.scheme + '://' + req.headers['host'] + '/'; + const successUrl = req.body?.successUrl ?? fallbackUrl; const failureUrl = req.body?.failureUrl ?? fallbackUrl; + // Lemon Squeezy returns the user to this function first, which verifies + // the subscription and then redirects to successUrl. + const verifyUrl = + req.body?.verifyUrl ?? new URL('/success', successUrl).toString(); const userId = req.headers['x-appwrite-user-id']; let userEmail = req.body?.email; @@ -48,7 +120,9 @@ export default async (context) => { context, userId, userEmail, - userName + userName, + verifyUrl, + successUrl ); if (!checkout) { error('Failed to create Lemon Squeezy checkout.'); @@ -68,22 +142,26 @@ export default async (context) => { } const eventType = req.headers['x-event-name']; - - const subscription = req.body; - const subscriptionUserId = subscription.meta.custom_data.user_id; - - if (eventType === 'subscription_created') { - await appwrite.createSubscription(subscriptionUserId); - log(`Created subscription for user ${subscriptionUserId}`); + if (!SubscriptionEvents.includes(eventType)) { + log(`Ignored Lemon Squeezy event ${eventType}.`); return res.json({ success: true }); } - if (eventType === 'subscription_expired') { - await appwrite.deleteSubscription(subscriptionUserId); - log(`Deleted subscription for user ${subscriptionUserId}`); + const subscriptionUserId = req.body.meta.custom_data?.user_id; + if (!subscriptionUserId) { + error( + `Subscription ${req.body.data.id} has no user_id in its custom data.` + ); return res.json({ success: true }); } + // Events can arrive late or out of order, so look the subscription up + // and apply its current status instead of trusting the event payload. + const subscription = await lemonsqueezy.getSubscription( + req.body.data.id + ); + await syncSubscription(subscription, subscriptionUserId); + return res.json({ success: true }); default: diff --git a/node/subscriptions-with-lemon-squeezy/static/index.html b/node/subscriptions-with-lemon-squeezy/static/index.html index 05c60f29..fc3c47e0 100644 --- a/node/subscriptions-with-lemon-squeezy/static/index.html +++ b/node/subscriptions-with-lemon-squeezy/static/index.html @@ -153,6 +153,7 @@
'{{APPWRITE_FUNCTION_ID}}', JSON.stringify({ failureUrl: window.location.href, + successUrl: window.location.href, email, name, }), diff --git a/node/subscriptions-with-stripe/README.md b/node/subscriptions-with-stripe/README.md index 90f0b3c2..a5a39e48 100644 --- a/node/subscriptions-with-stripe/README.md +++ b/node/subscriptions-with-stripe/README.md @@ -15,6 +15,7 @@ This endpoint initiates a Stripe checkout session for a subscription. The user I | x-appwrite-user-id | User ID from Appwrite. | Header | String | 642...7cd | | Content-Type | The content type of the request body | Header | `application/json` | N/A | | successUrl | The URL to redirect to after a successful payment. | Body | String | https://example.com/success | +| verifyUrl | Where the provider returns the user to verify the purchase. Defaults to `/success` on the `successUrl` origin. | Body | String | https:///success | | failureUrl | The URL to redirect to after a cancelled payment attempt. | Body | String | https://example.com/failure | **Response** @@ -31,9 +32,15 @@ Location: https://checkout.stripe.com/pay/cs_test_...#fidkdWxOYHwnP Location: https://mywebapp.com/cancel ``` +### `GET /success` + +Stripe redirects the user here after checkout. Stripe adds `session_id` to the redirect URL, and this endpoint fetches the subscription from Stripe with it. It provisions the user the same way the webhook does, then redirects to the `successUrl` given to the checkout endpoint. + +The webhook and this endpoint both provision the user, so either one works if the other fails. Running twice is safe: each run applies the latest status from the provider, and adding or removing a label that is already set or unset does nothing. + ### `POST /webhook` -This endpoint is a webhook that handles two types of events from Stripe: `customer.subscription.created` and `customer.subscription.deleted`. It validates the incoming request using the Stripe's validateWebhook method. If the validation fails, a `401` response is sent. +This endpoint is a webhook that handles Stripe events `customer.subscription.created`, `customer.subscription.updated` and `customer.subscription.deleted`. It validates the incoming request using the Stripe's validateWebhook method. If the validation fails, a `401` response is sent. The webhook payload isn't trusted: the function fetches the subscription from Stripe and adds or removes the `subscriber` label based on its current status. **Parameters** diff --git a/node/subscriptions-with-stripe/src/appwrite.js b/node/subscriptions-with-stripe/src/appwrite.js index fee242b1..59922399 100644 --- a/node/subscriptions-with-stripe/src/appwrite.js +++ b/node/subscriptions-with-stripe/src/appwrite.js @@ -18,11 +18,13 @@ class AppwriteService { * @returns {Promise} */ async deleteSubscription(userId) { - const labels = (await this.users.get(userId)).labels.filter( - (label) => label !== LabelsSubscriber - ); + const { labels } = await this.users.get(userId); + if (!labels.includes(LabelsSubscriber)) return; - await this.users.updateLabels(userId, labels); + await this.users.updateLabels( + userId, + labels.filter((label) => label !== LabelsSubscriber) + ); } /** @@ -30,10 +32,10 @@ class AppwriteService { * @returns {Promise} */ async createSubscription(userId) { - const labels = (await this.users.get(userId)).labels; - labels.push(LabelsSubscriber); + const { labels } = await this.users.get(userId); + if (labels.includes(LabelsSubscriber)) return; - await this.users.updateLabels(userId, labels); + await this.users.updateLabels(userId, [...labels, LabelsSubscriber]); } } diff --git a/node/subscriptions-with-stripe/src/main.js b/node/subscriptions-with-stripe/src/main.js index c40d605a..bfc50c8a 100644 --- a/node/subscriptions-with-stripe/src/main.js +++ b/node/subscriptions-with-stripe/src/main.js @@ -2,6 +2,10 @@ import StripeService from './stripe.js'; import AppwriteService from './appwrite.js'; import { getStaticFile, interpolate, throwIfMissing } from './utils.js'; +// past_due is the grace period while Stripe retries a failed renewal, so the +// customer keeps access. Every other status removes it. +const StatusesWithAccess = ['active', 'trialing', 'past_due']; + export default async (context) => { const { req, res, log, error } = context; @@ -10,6 +14,58 @@ export default async (context) => { 'STRIPE_WEBHOOK_SECRET', ]); + const appwrite = new AppwriteService(context.req.headers['x-appwrite-key']); + const stripe = new StripeService(); + + /** + * Gives or removes the subscriber label based on the subscription's current + * status in Stripe. Both the webhook and the /success redirect call this. + * They can run at the same time or out of order, but each one applies the + * latest status from Stripe and the label updates are idempotent, so they + * always settle on the same result. + * @param {string} subscriptionId + */ + async function syncSubscription(subscriptionId) { + const subscription = await stripe.getSubscription(subscriptionId); + const userId = subscription.metadata?.userId; + + if (!userId) { + error(`Subscription ${subscription.id} has no userId in its metadata.`); + return; + } + + if (StatusesWithAccess.includes(subscription.status)) { + await appwrite.createSubscription(userId); + log( + `Subscription ${subscription.id} is ${subscription.status}, user ${userId} has the subscriber label.` + ); + } else { + await appwrite.deleteSubscription(userId); + log( + `Subscription ${subscription.id} is ${subscription.status}, user ${userId} doesn't have the subscriber label.` + ); + } + } + + if (req.method === 'GET' && req.path === '/success') { + const sessionId = req.query.session_id; + let successUrl = '/'; + + try { + const session = await stripe.getCheckoutSession(sessionId); + successUrl = session.metadata?.successUrl || successUrl; + + if (session.subscription) { + await syncSubscription(/** @type {string} */ (session.subscription)); + } + } catch (err) { + // The webhook still provisions the user, so send them on regardless. + error(err); + } + + return res.redirect(successUrl, 303); + } + if (req.method === 'GET') { const html = interpolate(getStaticFile('index.html'), { APPWRITE_FUNCTION_API_ENDPOINT: process.env.APPWRITE_FUNCTION_API_ENDPOINT, @@ -20,15 +76,16 @@ export default async (context) => { return res.text(html, 200, { 'Content-Type': 'text/html; charset=utf-8' }); } - const appwrite = new AppwriteService(context.req.headers['x-appwrite-key']); - const stripe = new StripeService(); - switch (req.path) { case '/subscribe': const fallbackUrl = req.scheme + '://' + req.headers['host'] + '/'; const successUrl = req.body?.successUrl ?? fallbackUrl; const failureUrl = req.body?.failureUrl ?? fallbackUrl; + // Stripe returns the user to this function first, which verifies the + // subscription and then redirects to successUrl. + const verifyUrl = + req.body?.verifyUrl ?? new URL('/success', successUrl).toString(); const userId = req.headers['x-appwrite-user-id']; if (!userId) { @@ -39,6 +96,7 @@ export default async (context) => { const session = await stripe.checkoutSubscription( context, userId, + verifyUrl, successUrl, failureUrl ); @@ -62,22 +120,12 @@ export default async (context) => { context.log('Event:'); context.log(event); - if (event.type === 'customer.subscription.created') { - const session = event.data.object; - const userId = session.metadata.userId; - - await appwrite.createSubscription(userId); - log(`Created subscription for user ${userId}`); - return res.json({ success: true }); - } - - if (event.type === 'customer.subscription.deleted') { - const session = event.data.object; - const userId = session.metadata.userId; - - await appwrite.deleteSubscription(userId); - log(`Deleted subscription for user ${userId}`); - return res.json({ success: true }); + if ( + event.type === 'customer.subscription.created' || + event.type === 'customer.subscription.updated' || + event.type === 'customer.subscription.deleted' + ) { + await syncSubscription(event.data.object.id); } return res.json({ success: true }); diff --git a/node/subscriptions-with-stripe/src/stripe.js b/node/subscriptions-with-stripe/src/stripe.js index 554b8388..c169ee68 100644 --- a/node/subscriptions-with-stripe/src/stripe.js +++ b/node/subscriptions-with-stripe/src/stripe.js @@ -12,10 +12,17 @@ class StripeService { /** * @param {string} userId + * @param {string} verifyUrl The function's /success route * @param {string} successUrl * @param {string} failureUrl */ - async checkoutSubscription(context, userId, successUrl, failureUrl) { + async checkoutSubscription( + context, + userId, + verifyUrl, + successUrl, + failureUrl + ) { /** @type {import('stripe').Stripe.Checkout.SessionCreateParams.LineItem} */ const lineItem = { price_data: { @@ -35,9 +42,14 @@ class StripeService { return await this.client.checkout.sessions.create({ payment_method_types: ['card'], line_items: [lineItem], - success_url: successUrl, + // Stripe fills in {CHECKOUT_SESSION_ID}, so /success knows which + // session to verify before sending the user on to successUrl. + success_url: `${verifyUrl}?session_id={CHECKOUT_SESSION_ID}`, cancel_url: failureUrl, client_reference_id: userId, + metadata: { + successUrl, + }, subscription_data: { metadata: { userId, @@ -51,6 +63,24 @@ class StripeService { } } + /** + * @param {string} sessionId + * @returns {Promise} + */ + async getCheckoutSession(sessionId) { + return await this.client.checkout.sessions.retrieve(sessionId); + } + + /** + * Fetches a subscription from Stripe, so neither the webhook payload nor + * the redirect query string has to be trusted + * @param {string} subscriptionId + * @returns {Promise} + */ + async getSubscription(subscriptionId) { + return await this.client.subscriptions.retrieve(subscriptionId); + } + /** * @returns {import("stripe").Stripe.DiscriminatedEvent | null} */