diff --git a/node/payments-with-dodo-payments/.gitignore b/node/payments-with-dodo-payments/.gitignore new file mode 100644 index 00000000..46afb6b3 --- /dev/null +++ b/node/payments-with-dodo-payments/.gitignore @@ -0,0 +1,133 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# Directory used by Appwrite CLI for local development +.appwrite \ No newline at end of file diff --git a/node/payments-with-dodo-payments/.prettierrc.json b/node/payments-with-dodo-payments/.prettierrc.json new file mode 100644 index 00000000..0a725205 --- /dev/null +++ b/node/payments-with-dodo-payments/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": true, + "singleQuote": true +} diff --git a/node/payments-with-dodo-payments/README.md b/node/payments-with-dodo-payments/README.md new file mode 100644 index 00000000..ee793c32 --- /dev/null +++ b/node/payments-with-dodo-payments/README.md @@ -0,0 +1,151 @@ +# 💳 Node.js Payments with Dodo Payments Function + +Receive one-time payments with Dodo Payments and store paid orders in an Appwrite table. + +## 🧰 Usage + +### `GET /` + +Serves a demo page where a user can register, create a checkout and see their paid orders. + +### `POST /checkout` + +This endpoint creates a [Dodo Payments checkout session](https://docs.dodopayments.com/developer-resources/checkout-session) for the product in `DODO_PAYMENTS_PRODUCT_ID`. The user ID is fetched from the headers of the request and stored in the checkout metadata. If the user ID is not found or the checkout session can't be created, the request is redirected to the failure URL. + +**Parameters** + +| Name | Description | Location | Type | Sample Value | +| ------------------ | ------------------------------------------------------------------------------------------------- | -------- | ------------------ | --------------------------- | +| 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 return to after checkout. Defaults to the function domain. | 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 or failed checkout. Defaults to the function domain. | Body | String | https://example.com/failure | +| email | Optional. Prefills the customer's email address in the checkout. Test mode sends real emails too. | Body | String | jane@example.com | +| name | Optional. Prefills the customer's name in the checkout when `email` is also set. | Body | String | Jane Doe | + +**Response** + +Sample `303` Response: + +The response is a redirect to the Dodo Payments checkout URL, or to the failure URL if an error occurs. + +```text +Location: https://test.checkout.dodopayments.com/session/cks_Gi6KGJ2zFJo9rq9Ukifwa +``` + +```text +Location: https://example.com/failure +``` + +### `GET /success` + +Dodo Payments redirects the user here after checkout. Dodo Payments adds `payment_id` to the redirect URL, and this endpoint fetches the payment from Dodo Payments 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 receives Dodo Payments webhooks and handles the `payment.succeeded` event. It verifies the [Standard Webhooks](https://www.standardwebhooks.com/) signature in the `webhook-id`, `webhook-signature` and `webhook-timestamp` headers. If the verification fails, a `401` response is sent. The webhook payload isn't trusted: the function fetches the payment from Dodo Payments and stores the order only if its status is `succeeded`. + +On `payment.succeeded`, the function creates the orders database and table if they don't exist yet, then creates a row for the order. The row ID is the Dodo Payments payment ID, so a repeated delivery of the same event doesn't store the order twice. Only the user who paid can read the row. + +**Parameters** + +| Name | Description | Location | Type | Sample Value | +| ----------------- | ----------------------------------- | -------- | ------ | ------------------------------------------------------------------------------------ | +| None | Webhook payload from Dodo Payments. | Body | Object | [See Dodo Payments docs](https://docs.dodopayments.com/developer-resources/webhooks) | +| webhook-id | Unique ID of the webhook message. | Header | String | `msg_2Lh9...` | +| webhook-signature | Signature from Dodo Payments. | Header | String | [See Dodo Payments docs](https://docs.dodopayments.com/developer-resources/webhooks) | +| webhook-timestamp | Unix timestamp of the delivery. | Header | String | `1759938600` | + +**Response** + +Sample `200` Response: + +In case of the `payment.succeeded` event, a row for the order is created in the Appwrite table. Other events are acknowledged and ignored. + +```json +{ "success": true } +``` + +Sample `401` Response: + +```json +{ "success": false } +``` + +## ⚙️ Configuration + +| Setting | Value | +| ----------------- | -------------------------------------------------------------- | +| Runtime | Node (22) | +| Entrypoint | `src/main.js` | +| Build Commands | `npm install` | +| Permissions | `any` | +| Timeout (Seconds) | 15 | +| Scopes | `databases.write`, `tables.read`, `tables.write`, `rows.write` | + +> If using a demo web app to create order, make sure to add your function domain as a web platform to your Appwrite project. Doing this fixes CORS errors and allows proper functionality. + +> After deploying, add `https:///webhook` as an endpoint in the Dodo Payments dashboard under **Developer > Webhooks** and select the `payment.succeeded` event. An endpoint with no events selected receives every event type. Copy the endpoint's signing secret into `DODO_PAYMENTS_WEBHOOK_KEY`. + +## 🔒 Environment Variables + +### DODO_PAYMENTS_API_KEY + +API key for sending requests to the Dodo Payments API. The key needs write access to create checkout sessions. + +| Question | Answer | +| ------------- | -------------------------------------------------------------------------------------------------- | +| Required | Yes | +| Sample Value | `abcd...` | +| Documentation | [Dodo Payments: API keys](https://docs.dodopayments.com/api-reference/introduction#authentication) | + +### DODO_PAYMENTS_WEBHOOK_KEY + +Signing secret of the webhook endpoint, used to verify that webhooks come from Dodo Payments. + +| Question | Answer | +| ------------- | ------------------------------------------------------------------------------------- | +| Required | Yes | +| Sample Value | `whsec_MfKQ...` | +| Documentation | [Dodo Payments: Webhooks](https://docs.dodopayments.com/developer-resources/webhooks) | + +### DODO_PAYMENTS_PRODUCT_ID + +ID of the one-time product to sell. + +| Question | Answer | +| ------------- | -------------------------------------------------------------------------- | +| Required | Yes | +| Sample Value | `pdt_0NWa...` | +| Documentation | [Dodo Payments: Products](https://docs.dodopayments.com/features/products) | + +### DODO_PAYMENTS_ENVIRONMENT + +Dodo Payments environment to use, either `test_mode` or `live_mode`. Defaults to `test_mode`. Test mode and live mode have separate API keys, products and webhooks. + +| Question | Answer | +| ------------- | ----------------------------------------------------------------------------------------------------------- | +| Required | No | +| Sample Value | `test_mode` | +| Documentation | [Dodo Payments: Test mode vs live mode](https://docs.dodopayments.com/miscellaneous/test-mode-vs-live-mode) | + +### APPWRITE_DATABASE_ID + +The ID of the database to store the orders. Defaults to `orders`. + +| Question | Answer | +| ------------ | -------- | +| Required | No | +| Sample Value | `orders` | + +### APPWRITE_TABLE_ID + +The ID of the table to store the orders. Defaults to `orders`. + +| Question | Answer | +| ------------ | -------- | +| Required | No | +| Sample Value | `orders` | diff --git a/node/payments-with-dodo-payments/package-lock.json b/node/payments-with-dodo-payments/package-lock.json new file mode 100644 index 00000000..8d52f357 --- /dev/null +++ b/node/payments-with-dodo-payments/package-lock.json @@ -0,0 +1,109 @@ +{ + "name": "payments-with-dodo-payments", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "payments-with-dodo-payments", + "version": "1.0.0", + "dependencies": { + "dodopayments": "^2.54.0", + "node-appwrite": "^29.1.0" + }, + "devDependencies": { + "prettier": "^3.9.9" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/dodopayments": { + "version": "2.54.0", + "resolved": "https://registry.npmjs.org/dodopayments/-/dodopayments-2.54.0.tgz", + "integrity": "sha512-qszw1oaXxk4lyvj5XT/Mhpe6UPBhzjTZDzM86nuSRGZVrwltFncW6VhVNmO44q8m59Eb/8qS7inCQk36fc5xnA==", + "license": "Apache-2.0", + "dependencies": { + "standardwebhooks": "^1.0.0" + }, + "bin": { + "dodopayments": "bin/cli" + } + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/node-appwrite": { + "version": "29.1.0", + "resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-29.1.0.tgz", + "integrity": "sha512-/pRQEqTPFozg+2RFdtqqGY4cuqBkp8JK8PEuouhpm/f8UJPVk5AtsM+bSoEyIemJBcIkRNr2fwdeiqYxuOkUwg==", + "license": "BSD-3-Clause", + "dependencies": { + "json-bigint": "1.0.0", + "undici": "^6.28.1" + } + }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/undici": { + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + } + } +} diff --git a/node/payments-with-dodo-payments/package.json b/node/payments-with-dodo-payments/package.json new file mode 100644 index 00000000..e69dbba7 --- /dev/null +++ b/node/payments-with-dodo-payments/package.json @@ -0,0 +1,20 @@ +{ + "name": "payments-with-dodo-payments", + "version": "1.0.0", + "description": "", + "main": "src/main.js", + "type": "module", + "scripts": { + "format": "prettier --write ." + }, + "dependencies": { + "dodopayments": "^2.54.0", + "node-appwrite": "^29.1.0" + }, + "devDependencies": { + "prettier": "^3.9.9" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/node/payments-with-dodo-payments/src/appwrite.js b/node/payments-with-dodo-payments/src/appwrite.js new file mode 100644 index 00000000..a268b571 --- /dev/null +++ b/node/payments-with-dodo-payments/src/appwrite.js @@ -0,0 +1,94 @@ +import { Client, Permission, Role, TablesDB } from 'node-appwrite'; + +class AppwriteService { + constructor(apiKey) { + const client = new Client(); + client + .setEndpoint(process.env.APPWRITE_FUNCTION_API_ENDPOINT) + .setProject(process.env.APPWRITE_FUNCTION_PROJECT_ID) + .setKey(apiKey); + + this.tablesDB = new TablesDB(client); + } + + /** + * Creates the orders database and table if they don't exist yet + * @param {string} databaseId + * @param {string} tableId + * @returns {Promise} + */ + async setup(databaseId, tableId) { + if (await this.doesOrdersTableExist(databaseId, tableId)) { + return; + } + + await this.setupOrdersTable(databaseId, tableId); + } + + async doesOrdersTableExist(databaseId, tableId) { + try { + await this.tablesDB.getTable({ databaseId, tableId }); + return true; + } catch (err) { + if (err.code !== 404) throw err; + return false; + } + } + + async setupOrdersTable(databaseId, tableId) { + try { + await this.tablesDB.create({ databaseId, name: 'Orders Database' }); + } catch (err) { + if (err.code !== 409) throw err; + } + + // Columns passed to createTable are available as soon as it returns, so + // rows can be written right away. + try { + await this.tablesDB.createTable({ + databaseId, + tableId, + name: 'Orders', + rowSecurity: true, + columns: [ + { key: 'userId', type: 'varchar', size: 255, required: true }, + { key: 'orderId', type: 'varchar', size: 255, required: true }, + ], + }); + } catch (err) { + if (err.code !== 409) throw err; + } + } + + /** + * Stores a paid order. The webhook and the /success redirect can both run + * for the same payment, possibly at the same time. Using the order ID as the + * row ID makes the database reject the second write with a 409, so the + * order is only ever stored once. + * @param {string} databaseId + * @param {string} tableId + * @param {string} userId + * @param {string} orderId + * @returns {Promise} false if the order was already stored + */ + async createOrder(databaseId, tableId, userId, orderId) { + try { + await this.tablesDB.createRow({ + databaseId, + tableId, + rowId: orderId, + data: { + userId, + orderId, + }, + permissions: [Permission.read(Role.user(userId))], + }); + return true; + } catch (err) { + if (err.code !== 409) throw err; + return false; + } + } +} + +export default AppwriteService; diff --git a/node/payments-with-dodo-payments/src/dodopayments.js b/node/payments-with-dodo-payments/src/dodopayments.js new file mode 100644 index 00000000..2c3e07bc --- /dev/null +++ b/node/payments-with-dodo-payments/src/dodopayments.js @@ -0,0 +1,80 @@ +import DodoPayments from 'dodopayments'; + +class DodoPaymentsService { + constructor() { + this.client = new DodoPayments({ + bearerToken: process.env.DODO_PAYMENTS_API_KEY, + webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY, + // The SDK defaults to live mode, so fall back to test mode explicitly. + environment: process.env.DODO_PAYMENTS_ENVIRONMENT || 'test_mode', + }); + } + + async createCheckout( + context, + userId, + userEmail, + userName, + verifyUrl, + successUrl, + failureUrl + ) { + try { + return await this.client.checkoutSessions.create({ + product_cart: [ + { product_id: process.env.DODO_PAYMENTS_PRODUCT_ID, quantity: 1 }, + ], + // Test mode sends real emails, so only prefill an address the user entered. + customer: userEmail ? { email: userEmail, name: userName } : undefined, + metadata: { + user_id: userId, + success_url: successUrl, + }, + // Dodo Payments returns the user to this function first, with the + // payment ID in the query string. It verifies the payment and then + // redirects to success_url. + return_url: verifyUrl, + cancel_url: failureUrl, + }); + } catch (err) { + context.error(err); + return null; + } + } + + /** + * Fetches a payment from Dodo Payments, so neither the webhook payload nor + * the redirect query string has to be trusted + * @param {string} paymentId + */ + async getPayment(paymentId) { + return await this.client.payments.retrieve(paymentId); + } + + /** + * Verifies the Standard Webhooks signature of a Dodo Payments webhook + * @param {*} context + * @returns {*} The verified event, or null if verification failed + */ + validateWebhook(context) { + try { + const { headers } = context.req; + // unwrap() only verifies the signature when headers are passed. + const event = this.client.webhooks.unwrap(context.req.bodyText, { + headers: { + 'webhook-id': headers['webhook-id'], + 'webhook-signature': headers['webhook-signature'], + 'webhook-timestamp': headers['webhook-timestamp'], + }, + }); + + context.log('Webhook signature is valid.'); + return event; + } catch (err) { + context.error(err); + return null; + } + } +} + +export default DodoPaymentsService; diff --git a/node/payments-with-dodo-payments/src/main.js b/node/payments-with-dodo-payments/src/main.js new file mode 100644 index 00000000..57f54ca4 --- /dev/null +++ b/node/payments-with-dodo-payments/src/main.js @@ -0,0 +1,144 @@ +import AppwriteService from './appwrite.js'; +import { getStaticFile, interpolate, throwIfMissing } from './utils.js'; +import DodoPaymentsService from './dodopayments.js'; + +export default async (context) => { + const { req, res, log, error } = context; + + throwIfMissing(process.env, [ + 'DODO_PAYMENTS_API_KEY', + 'DODO_PAYMENTS_WEBHOOK_KEY', + 'DODO_PAYMENTS_PRODUCT_ID', + ]); + + const databaseId = process.env.APPWRITE_DATABASE_ID || 'orders'; + const tableId = process.env.APPWRITE_TABLE_ID || 'orders'; + + const appwrite = new AppwriteService(req.headers['x-appwrite-key']); + const dodopayments = new DodoPaymentsService(); + + /** + * Stores the order if Dodo Payments reports the payment as succeeded. Both + * the webhook and the /success redirect call this, so the order is stored + * even if one of them never arrives. + * @param {string} paymentId + */ + async function fulfillPayment(paymentId) { + const payment = await dodopayments.getPayment(paymentId); + + if (payment.status !== 'succeeded') { + log(`Payment ${payment.payment_id} is ${payment.status}, not fulfilling.`); + return payment; + } + + const orderUserId = payment.metadata?.user_id; + const orderId = payment.payment_id; + + if (!orderUserId) { + error(`Payment ${orderId} has no user_id in its metadata.`); + return payment; + } + + await appwrite.setup(databaseId, tableId); + + const created = await appwrite.createOrder( + databaseId, + tableId, + orderUserId, + orderId + ); + log( + created + ? `Created order row for user ${orderUserId} with Dodo Payments payment ID ${orderId}.` + : `Order ${orderId} was already stored.` + ); + return payment; + } + + if (req.method === 'GET' && req.path === '/success') { + let successUrl = '/'; + + try { + // A cancelled or failed checkout can come back without a payment ID. + if (req.query.payment_id) { + const payment = await fulfillPayment(req.query.payment_id); + successUrl = payment.metadata?.success_url || 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, + APPWRITE_FUNCTION_PROJECT_ID: process.env.APPWRITE_FUNCTION_PROJECT_ID, + APPWRITE_FUNCTION_ID: process.env.APPWRITE_FUNCTION_ID, + APPWRITE_DATABASE_ID: databaseId, + APPWRITE_TABLE_ID: tableId, + }); + + return res.text(html, 200, { 'Content-Type': 'text/html; charset=utf-8' }); + } + + switch (req.path) { + case '/checkout': + const body = req.bodyJson; + const fallbackUrl = req.scheme + '://' + req.headers['host'] + '/'; + const successUrl = body.successUrl ?? fallbackUrl; + const failureUrl = body.failureUrl ?? fallbackUrl; + + // Dodo Payments returns the user to this function first, which verifies + // the payment and then redirects to successUrl. + const verifyUrl = + body.verifyUrl ?? new URL('/success', successUrl).toString(); + + const userId = req.headers['x-appwrite-user-id']; + + if (!userId) { + error('User ID not found in request.'); + return res.redirect(failureUrl, 303); + } + + const checkout = await dodopayments.createCheckout( + context, + userId, + body.email, + body.name, + verifyUrl, + successUrl, + failureUrl + ); + if (!checkout?.checkout_url) { + error('Failed to create Dodo Payments checkout.'); + return res.redirect(failureUrl, 303); + } + + log( + `Created Dodo Payments checkout ${checkout.session_id} for user ${userId}.` + ); + return res.redirect(checkout.checkout_url, 303); + + case '/webhook': + const event = dodopayments.validateWebhook(context); + if (!event) { + return res.json({ success: false }, 401); + } + + if (event.type !== 'payment.succeeded') { + log(`Ignored Dodo Payments event ${event.type}.`); + return res.json({ success: true }); + } + + // Look the payment up instead of trusting the event payload. + await fulfillPayment(event.data.payment_id); + return res.json({ success: true }); + + default: + return res.text('Not Found', 404); + } +}; diff --git a/node/payments-with-dodo-payments/src/utils.js b/node/payments-with-dodo-payments/src/utils.js new file mode 100644 index 00000000..1a8951bd --- /dev/null +++ b/node/payments-with-dodo-payments/src/utils.js @@ -0,0 +1,43 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; + +/** + * Throws an error if any of the keys are missing from the object + * @param {*} obj + * @param {string[]} keys + * @throws {Error} + */ +export function throwIfMissing(obj, keys) { + const missing = []; + for (let key of keys) { + if (!(key in obj) || !obj[key]) { + missing.push(key); + } + } + if (missing.length > 0) { + throw new Error(`Missing required fields: ${missing.join(', ')}`); + } +} + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const staticFolder = path.join(__dirname, '../static'); + +/** + * Returns the contents of a file in the static folder + * @param {string} fileName + * @returns {string} Contents of static/{fileName} + */ +export function getStaticFile(fileName) { + return fs.readFileSync(path.join(staticFolder, fileName)).toString(); +} + +/** + * @param {string} template + * @param {Record} values + * @returns {string} + */ +export function interpolate(template, values) { + return template.replace(/{{([^}]+)}}/g, (_, key) => values[key] || ''); +} diff --git a/node/payments-with-dodo-payments/static/index.html b/node/payments-with-dodo-payments/static/index.html new file mode 100644 index 00000000..2b158154 --- /dev/null +++ b/node/payments-with-dodo-payments/static/index.html @@ -0,0 +1,548 @@ + + + + + + Payments with Dodo Payments + + + + + + + +
+
+

+ Payments with Dodo Payments_ +

+

+ Use this demo to create a Dodo Payments checkout. Once the payment + succeeds, the function stores the order as a row in an Appwrite table. + You can use this to give access to video courses, SaaS plans, or any + other digital goods and services. +

+
+ +
+ + + + + +
+
+ + + + + diff --git a/node/subscriptions-with-dodo-payments/.gitignore b/node/subscriptions-with-dodo-payments/.gitignore new file mode 100644 index 00000000..46afb6b3 --- /dev/null +++ b/node/subscriptions-with-dodo-payments/.gitignore @@ -0,0 +1,133 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# Directory used by Appwrite CLI for local development +.appwrite \ No newline at end of file diff --git a/node/subscriptions-with-dodo-payments/.prettierrc.json b/node/subscriptions-with-dodo-payments/.prettierrc.json new file mode 100644 index 00000000..0a725205 --- /dev/null +++ b/node/subscriptions-with-dodo-payments/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": true, + "singleQuote": true +} diff --git a/node/subscriptions-with-dodo-payments/README.md b/node/subscriptions-with-dodo-payments/README.md new file mode 100644 index 00000000..00d968e8 --- /dev/null +++ b/node/subscriptions-with-dodo-payments/README.md @@ -0,0 +1,140 @@ +# 💳 Node.js Subscriptions with Dodo Payments Function + +Receive recurring payments with Dodo Payments and grant subscribers extra permissions. + +## 🧰 Usage + +### `GET /` + +Serves a demo page where a user can register, subscribe and see their subscription status. + +### `POST /subscribe` + +This endpoint creates a [Dodo Payments checkout session](https://docs.dodopayments.com/developer-resources/checkout-session) for the subscription product in `DODO_PAYMENTS_PRODUCT_ID`. The user ID is fetched from the headers of the request and stored in the checkout metadata, which Dodo Payments copies to the subscription. If the user ID is not found or the checkout session can't be created, the request is redirected to the failure URL. + +**Parameters** + +| Name | Description | Location | Type | Sample Value | +| ------------------ | ------------------------------------------------------------------------------------------------- | -------- | ------------------ | --------------------------- | +| 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 return to after checkout. Defaults to the function domain. | 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 or failed checkout. Defaults to the function domain. | Body | String | https://example.com/failure | +| email | Optional. Prefills the customer's email address in the checkout. Test mode sends real emails too. | Body | String | jane@example.com | +| name | Optional. Prefills the customer's name in the checkout when `email` is also set. | Body | String | Jane Doe | + +**Response** + +Sample `303` Response: + +The response is a redirect to the Dodo Payments checkout URL, or to the failure URL if an error occurs. + +```text +Location: https://test.checkout.dodopayments.com/session/cks_Gi6KGJ2zFJo9rq9Ukifwa +``` + +```text +Location: https://example.com/failure +``` + +### `GET /success` + +Dodo Payments redirects the user here after checkout. Dodo Payments adds `subscription_id` to the redirect URL, and this endpoint fetches the subscription from Dodo Payments 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 receives Dodo Payments webhooks and handles every `subscription.*` event. It verifies the [Standard Webhooks](https://www.standardwebhooks.com/) signature in the `webhook-id`, `webhook-signature` and `webhook-timestamp` headers. If the verification fails, a `401` response is sent. The webhook payload isn't trusted: the function fetches the subscription from Dodo Payments and adds or removes the `subscriber` label based on its current status. + +Webhooks can arrive late, twice or out of order, but each one carries the subscription's current status. The function decides from that status rather than the event type, so a delayed event can't give access back after a cancellation: + +| Subscription status | Effect | +| ---------------------------------------------------------------- | -------------------------------------------- | +| `active`, `past_due` | Adds the `subscriber` label to the user | +| `on_hold`, `paused`, `cancelled`, `expired`, `failed`, `pending` | Removes the `subscriber` label from the user | + +`past_due` is the grace period while Dodo Payments retries a failed renewal, so the customer keeps access until the subscription moves to `on_hold` or `cancelled`. See the [subscription integration guide](https://docs.dodopayments.com/developer-resources/subscription-integration-guide) for the full lifecycle. + +**Parameters** + +| Name | Description | Location | Type | Sample Value | +| ----------------- | ----------------------------------- | -------- | ------ | ------------------------------------------------------------------------------------ | +| None | Webhook payload from Dodo Payments. | Body | Object | [See Dodo Payments docs](https://docs.dodopayments.com/developer-resources/webhooks) | +| webhook-id | Unique ID of the webhook message. | Header | String | `msg_2Lh9...` | +| webhook-signature | Signature from Dodo Payments. | Header | String | [See Dodo Payments docs](https://docs.dodopayments.com/developer-resources/webhooks) | +| webhook-timestamp | Unix timestamp of the delivery. | Header | String | `1759938600` | + +**Response** + +Sample `200` Response: + +In case of a `subscription.*` event, the `subscriber` label is added to or removed from the user. Other events are acknowledged and ignored. + +```json +{ "success": true } +``` + +Sample `401` Response: + +```json +{ "success": false } +``` + +## ⚙️ Configuration + +| Setting | Value | +| ----------------- | --------------------------- | +| Runtime | Node (22) | +| Entrypoint | `src/main.js` | +| Build Commands | `npm install` | +| Permissions | `any` | +| Timeout (Seconds) | 15 | +| Scopes | `users.read`, `users.write` | + +> If using a demo web app to subscribe, make sure to add your function domain as a web platform to your Appwrite project. Doing this fixes CORS errors and allows proper functionality. + +> After deploying, add `https:///webhook` as an endpoint in the Dodo Payments dashboard under **Developer > Webhooks** and select the subscription events. An endpoint with no events selected receives every event type. Copy the endpoint's signing secret into `DODO_PAYMENTS_WEBHOOK_KEY`. + +## 🔒 Environment Variables + +### DODO_PAYMENTS_API_KEY + +API key for sending requests to the Dodo Payments API. The key needs write access to create checkout sessions. + +| Question | Answer | +| ------------- | -------------------------------------------------------------------------------------------------- | +| Required | Yes | +| Sample Value | `abcd...` | +| Documentation | [Dodo Payments: API keys](https://docs.dodopayments.com/api-reference/introduction#authentication) | + +### DODO_PAYMENTS_WEBHOOK_KEY + +Signing secret of the webhook endpoint, used to verify that webhooks come from Dodo Payments. + +| Question | Answer | +| ------------- | ------------------------------------------------------------------------------------- | +| Required | Yes | +| Sample Value | `whsec_MfKQ...` | +| Documentation | [Dodo Payments: Webhooks](https://docs.dodopayments.com/developer-resources/webhooks) | + +### DODO_PAYMENTS_PRODUCT_ID + +ID of the subscription product to sell. + +| Question | Answer | +| ------------- | ---------------------------------------------------------------------------------------------------------------- | +| Required | Yes | +| Sample Value | `pdt_0NWa...` | +| Documentation | [Dodo Payments: Subscriptions](https://docs.dodopayments.com/developer-resources/subscription-integration-guide) | + +### DODO_PAYMENTS_ENVIRONMENT + +Dodo Payments environment to use, either `test_mode` or `live_mode`. Defaults to `test_mode`. Test mode and live mode have separate API keys, products and webhooks. + +| Question | Answer | +| ------------- | ----------------------------------------------------------------------------------------------------------- | +| Required | No | +| Sample Value | `test_mode` | +| Documentation | [Dodo Payments: Test mode vs live mode](https://docs.dodopayments.com/miscellaneous/test-mode-vs-live-mode) | diff --git a/node/subscriptions-with-dodo-payments/package-lock.json b/node/subscriptions-with-dodo-payments/package-lock.json new file mode 100644 index 00000000..6b7d336b --- /dev/null +++ b/node/subscriptions-with-dodo-payments/package-lock.json @@ -0,0 +1,109 @@ +{ + "name": "subscriptions-with-dodo-payments", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "subscriptions-with-dodo-payments", + "version": "1.0.0", + "dependencies": { + "dodopayments": "^2.54.0", + "node-appwrite": "^29.1.0" + }, + "devDependencies": { + "prettier": "^3.9.9" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/dodopayments": { + "version": "2.54.0", + "resolved": "https://registry.npmjs.org/dodopayments/-/dodopayments-2.54.0.tgz", + "integrity": "sha512-qszw1oaXxk4lyvj5XT/Mhpe6UPBhzjTZDzM86nuSRGZVrwltFncW6VhVNmO44q8m59Eb/8qS7inCQk36fc5xnA==", + "license": "Apache-2.0", + "dependencies": { + "standardwebhooks": "^1.0.0" + }, + "bin": { + "dodopayments": "bin/cli" + } + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/node-appwrite": { + "version": "29.1.0", + "resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-29.1.0.tgz", + "integrity": "sha512-/pRQEqTPFozg+2RFdtqqGY4cuqBkp8JK8PEuouhpm/f8UJPVk5AtsM+bSoEyIemJBcIkRNr2fwdeiqYxuOkUwg==", + "license": "BSD-3-Clause", + "dependencies": { + "json-bigint": "1.0.0", + "undici": "^6.28.1" + } + }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/undici": { + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + } + } +} diff --git a/node/subscriptions-with-dodo-payments/package.json b/node/subscriptions-with-dodo-payments/package.json new file mode 100644 index 00000000..5f6269e5 --- /dev/null +++ b/node/subscriptions-with-dodo-payments/package.json @@ -0,0 +1,20 @@ +{ + "name": "subscriptions-with-dodo-payments", + "version": "1.0.0", + "description": "", + "main": "src/main.js", + "type": "module", + "scripts": { + "format": "prettier --write ." + }, + "dependencies": { + "dodopayments": "^2.54.0", + "node-appwrite": "^29.1.0" + }, + "devDependencies": { + "prettier": "^3.9.9" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/node/subscriptions-with-dodo-payments/src/appwrite.js b/node/subscriptions-with-dodo-payments/src/appwrite.js new file mode 100644 index 00000000..8351e136 --- /dev/null +++ b/node/subscriptions-with-dodo-payments/src/appwrite.js @@ -0,0 +1,45 @@ +import { Client, Users } from 'node-appwrite'; + +const LabelsSubscriber = 'subscriber'; + +class AppwriteService { + constructor(apiKey) { + const client = new Client(); + client + .setEndpoint(process.env.APPWRITE_FUNCTION_API_ENDPOINT) + .setProject(process.env.APPWRITE_FUNCTION_PROJECT_ID) + .setKey(apiKey); + + this.users = new Users(client); + } + + /** + * @param {string} userId + * @returns {Promise} + */ + async deleteSubscription(userId) { + const { labels } = await this.users.get({ userId }); + if (!labels.includes(LabelsSubscriber)) return; + + await this.users.updateLabels({ + userId, + labels: labels.filter((label) => label !== LabelsSubscriber), + }); + } + + /** + * @param {string} userId + * @returns {Promise} + */ + async createSubscription(userId) { + const { labels } = await this.users.get({ userId }); + if (labels.includes(LabelsSubscriber)) return; + + await this.users.updateLabels({ + userId, + labels: [...labels, LabelsSubscriber], + }); + } +} + +export default AppwriteService; diff --git a/node/subscriptions-with-dodo-payments/src/dodopayments.js b/node/subscriptions-with-dodo-payments/src/dodopayments.js new file mode 100644 index 00000000..7fb7de45 --- /dev/null +++ b/node/subscriptions-with-dodo-payments/src/dodopayments.js @@ -0,0 +1,80 @@ +import DodoPayments from 'dodopayments'; + +class DodoPaymentsService { + constructor() { + this.client = new DodoPayments({ + bearerToken: process.env.DODO_PAYMENTS_API_KEY, + webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY, + // The SDK defaults to live mode, so fall back to test mode explicitly. + environment: process.env.DODO_PAYMENTS_ENVIRONMENT || 'test_mode', + }); + } + + async createSubscription( + context, + userId, + userEmail, + userName, + verifyUrl, + successUrl, + failureUrl + ) { + try { + return await this.client.checkoutSessions.create({ + product_cart: [ + { product_id: process.env.DODO_PAYMENTS_PRODUCT_ID, quantity: 1 }, + ], + // Test mode sends real emails, so only prefill an address the user entered. + customer: userEmail ? { email: userEmail, name: userName } : undefined, + metadata: { + user_id: userId, + success_url: successUrl, + }, + // Dodo Payments returns the user to this function first, with the + // subscription ID in the query string. It verifies the subscription and then + // redirects to success_url. + return_url: verifyUrl, + cancel_url: failureUrl, + }); + } catch (err) { + context.error(err); + return null; + } + } + + /** + * Fetches a subscription from Dodo Payments, so neither the webhook payload + * nor the redirect query string has to be trusted + * @param {string} subscriptionId + */ + async getSubscription(subscriptionId) { + return await this.client.subscriptions.retrieve(subscriptionId); + } + + /** + * Verifies the Standard Webhooks signature of a Dodo Payments webhook + * @param {*} context + * @returns {*} The verified event, or null if verification failed + */ + validateWebhook(context) { + try { + const { headers } = context.req; + // unwrap() only verifies the signature when headers are passed. + const event = this.client.webhooks.unwrap(context.req.bodyText, { + headers: { + 'webhook-id': headers['webhook-id'], + 'webhook-signature': headers['webhook-signature'], + 'webhook-timestamp': headers['webhook-timestamp'], + }, + }); + + context.log('Webhook signature is valid.'); + return event; + } catch (err) { + context.error(err); + return null; + } + } +} + +export default DodoPaymentsService; diff --git a/node/subscriptions-with-dodo-payments/src/main.js b/node/subscriptions-with-dodo-payments/src/main.js new file mode 100644 index 00000000..b3ad4ea4 --- /dev/null +++ b/node/subscriptions-with-dodo-payments/src/main.js @@ -0,0 +1,139 @@ +import AppwriteService from './appwrite.js'; +import { getStaticFile, interpolate, throwIfMissing } from './utils.js'; +import DodoPaymentsService from './dodopayments.js'; + +// past_due is the grace period while Dodo Payments retries a failed renewal, +// so the customer keeps access. Every other status removes it. +const StatusesWithAccess = ['active', 'past_due']; + +export default async (context) => { + const { req, res, log, error } = context; + + throwIfMissing(process.env, [ + 'DODO_PAYMENTS_API_KEY', + 'DODO_PAYMENTS_WEBHOOK_KEY', + 'DODO_PAYMENTS_PRODUCT_ID', + ]); + + const appwrite = new AppwriteService(req.headers['x-appwrite-key']); + const dodopayments = new DodoPaymentsService(); + + /** + * Gives or removes the subscriber label based on the subscription's current + * status in Dodo Payments. 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 Dodo Payments and the label updates are + * idempotent, so they always settle on the same result. + * @param {string} subscriptionId + */ + async function syncSubscription(subscriptionId) { + const subscription = await dodopayments.getSubscription(subscriptionId); + const subscriptionUserId = subscription.metadata?.user_id; + + if (!subscriptionUserId) { + error(`Subscription ${subscriptionId} has no user_id in its metadata.`); + return subscription; + } + + if (StatusesWithAccess.includes(subscription.status)) { + await appwrite.createSubscription(subscriptionUserId); + log( + `Subscription ${subscriptionId} is ${subscription.status}, user ${subscriptionUserId} has the subscriber label.` + ); + } else { + await appwrite.deleteSubscription(subscriptionUserId); + log( + `Subscription ${subscriptionId} is ${subscription.status}, user ${subscriptionUserId} doesn't have the subscriber label.` + ); + } + return subscription; + } + + if (req.method === 'GET' && req.path === '/success') { + let successUrl = '/'; + + try { + // A cancelled or failed checkout can come back without a subscription ID. + if (req.query.subscription_id) { + const subscription = await syncSubscription( + req.query.subscription_id + ); + successUrl = subscription.metadata?.success_url || successUrl; + } + } 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, + APPWRITE_FUNCTION_PROJECT_ID: process.env.APPWRITE_FUNCTION_PROJECT_ID, + APPWRITE_FUNCTION_ID: process.env.APPWRITE_FUNCTION_ID, + }); + + return res.text(html, 200, { 'Content-Type': 'text/html; charset=utf-8' }); + } + + switch (req.path) { + case '/subscribe': + const body = req.bodyJson; + const fallbackUrl = req.scheme + '://' + req.headers['host'] + '/'; + const successUrl = body.successUrl ?? fallbackUrl; + const failureUrl = body.failureUrl ?? fallbackUrl; + + // Dodo Payments returns the user to this function first, which verifies + // the subscription and then redirects to successUrl. + const verifyUrl = + body.verifyUrl ?? new URL('/success', successUrl).toString(); + + const userId = req.headers['x-appwrite-user-id']; + + if (!userId) { + error('User ID not found in request.'); + return res.redirect(failureUrl, 303); + } + + const checkout = await dodopayments.createSubscription( + context, + userId, + body.email, + body.name, + verifyUrl, + successUrl, + failureUrl + ); + if (!checkout?.checkout_url) { + error('Failed to create Dodo Payments checkout.'); + return res.redirect(failureUrl, 303); + } + + log( + `Created Dodo Payments checkout ${checkout.session_id} for user ${userId}.` + ); + return res.redirect(checkout.checkout_url, 303); + + case '/webhook': + const event = dodopayments.validateWebhook(context); + if (!event) { + return res.json({ success: false }, 401); + } + + if (!event.type.startsWith('subscription.')) { + log(`Ignored Dodo Payments event ${event.type}.`); + 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. + await syncSubscription(event.data.subscription_id); + return res.json({ success: true }); + + default: + return res.text('Not Found', 404); + } +}; diff --git a/node/subscriptions-with-dodo-payments/src/utils.js b/node/subscriptions-with-dodo-payments/src/utils.js new file mode 100644 index 00000000..1a8951bd --- /dev/null +++ b/node/subscriptions-with-dodo-payments/src/utils.js @@ -0,0 +1,43 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; + +/** + * Throws an error if any of the keys are missing from the object + * @param {*} obj + * @param {string[]} keys + * @throws {Error} + */ +export function throwIfMissing(obj, keys) { + const missing = []; + for (let key of keys) { + if (!(key in obj) || !obj[key]) { + missing.push(key); + } + } + if (missing.length > 0) { + throw new Error(`Missing required fields: ${missing.join(', ')}`); + } +} + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const staticFolder = path.join(__dirname, '../static'); + +/** + * Returns the contents of a file in the static folder + * @param {string} fileName + * @returns {string} Contents of static/{fileName} + */ +export function getStaticFile(fileName) { + return fs.readFileSync(path.join(staticFolder, fileName)).toString(); +} + +/** + * @param {string} template + * @param {Record} values + * @returns {string} + */ +export function interpolate(template, values) { + return template.replace(/{{([^}]+)}}/g, (_, key) => values[key] || ''); +} diff --git a/node/subscriptions-with-dodo-payments/static/index.html b/node/subscriptions-with-dodo-payments/static/index.html new file mode 100644 index 00000000..7b2f8348 --- /dev/null +++ b/node/subscriptions-with-dodo-payments/static/index.html @@ -0,0 +1,518 @@ + + + + + + Subscriptions with Dodo Payments + + + + + + + +
+
+

+ Subscriptions with Dodo Payments_ +

+

+ Use this demo to create a Dodo Payments subscription. While the + subscription is active, the user has a subscriber label. + You can use the label to give permissions to subscribers only. +

+
+ +
+ + + + + +
+
+ + + + +