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..4efa728a --- /dev/null +++ b/node/payments-with-dodo-payments/README.md @@ -0,0 +1,153 @@ +# 💳 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 when the request comes through it. Through the Appwrite API the function can't see its domain, so pass it. | Body | String | https://example.com/success | +| verifyUrl | Optional. This function's `/success` URL. Dodo Payments returns the user there to verify the payment, then the function redirects to `successUrl`. Without it, the user returns straight to `successUrl` and only the webhook stores the order. | Body | String | https:///success | +| failureUrl | The URL to redirect to after a cancelled or failed checkout. Defaults like `successUrl`. | 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 when the checkout request sets `verifyUrl`. 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. So is a payment with no `user_id` in its metadata, because Dodo Payments would otherwise retry it for about 28 hours. + +```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` | + +> `successUrl`, `failureUrl` and `verifyUrl` come from the caller, and Dodo Payments and `/success` redirect the user to them. Before going live, check them against your own domains in `POST /checkout`, so the function can't be used to redirect people to other sites. + +> 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..f4623e85 --- /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, + returnUrl, + 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 adds the payment ID to this URL. When it's the + // function's /success route, the function verifies the payment there + // and then redirects to success_url. + return_url: returnUrl, + 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..5b4105ef --- /dev/null +++ b/node/payments-with-dodo-payments/src/main.js @@ -0,0 +1,148 @@ +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; + + // verifyUrl is this function's /success route, which verifies the + // payment and then redirects to successUrl. Called through the Appwrite + // API, the function can't tell its own domain, so the caller passes it. + // Without it, the user returns straight to successUrl and the webhook + // stores the order. + const returnUrl = body.verifyUrl ?? successUrl; + + 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, + returnUrl, + 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..5fe64661 --- /dev/null +++ b/node/payments-with-dodo-payments/static/index.html @@ -0,0 +1,551 @@ + + + + + + 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..f7dc9615 --- /dev/null +++ b/node/subscriptions-with-dodo-payments/README.md @@ -0,0 +1,144 @@ +# 💳 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 when the request comes through it. Through the Appwrite API the function can't see its domain, so pass it. | Body | String | https://example.com/success | +| verifyUrl | Optional. This function's `/success` URL. Dodo Payments returns the user there to verify the subscription, then the function redirects to `successUrl`. Without it, the user returns straight to `successUrl` and only the webhook gives the label. | Body | String | https:///success | +| failureUrl | The URL to redirect to after a cancelled or failed checkout. Defaults like `successUrl`. | 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 when the checkout request sets `verifyUrl`. Dodo Payments adds `subscription_id` to the redirect URL, and this endpoint fetches the subscription from Dodo Payments with it. If the subscription gives access, it adds the `subscriber` label, then redirects to the `successUrl` given to the checkout endpoint. + +The webhook and this endpoint both give the label, so either one works if the other fails. The redirect can arrive while the subscription is still `pending`, so this endpoint doesn't remove the label for a status without access. That's left to the webhook. + +### `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. Each one looks up the subscription's current status in Dodo Payments and applies it, whatever 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. + +A webhook and the `/success` redirect, or two webhooks, can run at the same time. After each label update, the function looks the subscription up again and applies the newer status if it changed in the meantime, so an overlapping run can't leave the label out of date. + +**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. So is a subscription with no `user_id` in its metadata, or one whose Appwrite user no longer exists, because Dodo Payments would otherwise retry it for about 28 hours. + +```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` | + +> `successUrl`, `failureUrl` and `verifyUrl` come from the caller, and Dodo Payments and `/success` redirect the user to them. Before going live, check them against your own domains in `POST /subscribe`, so the function can't be used to redirect people to other sites. + +> 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..1bf0829d --- /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, + returnUrl, + 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 adds the subscription ID to this URL. When it's the + // function's /success route, the function verifies the subscription + // there and then redirects to success_url. + return_url: returnUrl, + 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..c4a7dadf --- /dev/null +++ b/node/subscriptions-with-dodo-payments/src/main.js @@ -0,0 +1,180 @@ +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, and the label updates are idempotent, so running twice is safe. + * @param {string} subscriptionId + * @param {{ grantOnly?: boolean }} [options] With grantOnly, a status without + * access leaves the label as it is instead of removing it. + */ + async function syncSubscription(subscriptionId, { grantOnly = false } = {}) { + 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; + } + + const hasAccess = StatusesWithAccess.includes(subscription.status); + if (!hasAccess && grantOnly) { + log( + `Subscription ${subscriptionId} is ${subscription.status}, leaving the subscriber label to the webhook.` + ); + return subscription; + } + if (!(await setSubscriberLabel(subscriptionUserId, hasAccess))) { + return subscription; + } + log( + `Subscription ${subscriptionId} is ${subscription.status}, user ${subscriptionUserId} ${hasAccess ? 'has' : "doesn't have"} the subscriber label.` + ); + + // Another run can apply a newer status between this run's lookup and its + // label update, which this run would then overwrite. Looking the + // subscription up again after the update catches that, and the newer + // status wins. + const latest = await dodopayments.getSubscription(subscriptionId); + const latestHasAccess = StatusesWithAccess.includes(latest.status); + if (latestHasAccess !== hasAccess) { + await setSubscriberLabel(subscriptionUserId, latestHasAccess); + log( + `Subscription ${subscriptionId} changed to ${latest.status} during the update, user ${subscriptionUserId} ${latestHasAccess ? 'has' : "doesn't have"} the subscriber label.` + ); + } + return latest; + } + + /** + * @param {string} userId + * @param {boolean} hasAccess + * @returns {Promise} false if the user no longer exists + */ + async function setSubscriberLabel(userId, hasAccess) { + try { + if (hasAccess) { + await appwrite.createSubscription(userId); + } else { + await appwrite.deleteSubscription(userId); + } + return true; + } catch (err) { + // Dodo Payments retries any non-2xx response, and a deleted user will + // never come back, so acknowledge the event instead. + if (err.code !== 404) throw err; + error(`User ${userId} no longer exists.`); + return false; + } + } + + 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) { + // The redirect can arrive while the subscription is still pending, so + // this only gives the label and leaves removing it to the webhook. + const subscription = await syncSubscription(req.query.subscription_id, { + grantOnly: true, + }); + 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; + + // verifyUrl is this function's /success route, which verifies the + // subscription and then redirects to successUrl. Called through the + // Appwrite API, the function can't tell its own domain, so the caller + // passes it. Without it, the user returns straight to successUrl and the + // webhook gives the label. + const returnUrl = body.verifyUrl ?? successUrl; + + 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, + returnUrl, + 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..1b1eb9d4 --- /dev/null +++ b/node/subscriptions-with-dodo-payments/static/index.html @@ -0,0 +1,521 @@ + + + + + + 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. +

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