diff --git a/node/payments-with-stripe/.gitignore b/node/payments-with-stripe/.gitignore new file mode 100644 index 00000000..6a7d6d8e --- /dev/null +++ b/node/payments-with-stripe/.gitignore @@ -0,0 +1,130 @@ +# 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.* \ No newline at end of file diff --git a/node/payments-with-stripe/.prettierrc.json b/node/payments-with-stripe/.prettierrc.json new file mode 100644 index 00000000..0a725205 --- /dev/null +++ b/node/payments-with-stripe/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": true, + "singleQuote": true +} diff --git a/node/payments-with-stripe/README.md b/node/payments-with-stripe/README.md new file mode 100644 index 00000000..44f43977 --- /dev/null +++ b/node/payments-with-stripe/README.md @@ -0,0 +1,129 @@ +# 💳 Node.js Stripe Payments Function + +Receive card payments and store paid orders. + +## 🧰 Usage + +### `POST /checkout` + +This endpoint initiates a Stripe checkout session. The user ID is fetched from the headers of the request. If the user ID is not found or a Stripe checkout session could not be created, the request will be redirected to a cancel 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 redirect to after a successful payment. | Body | String | https://example.com/success | +| failureUrl | The URL to redirect to after a cancelled payment attempt. | Body | String | https://example.com/failure | + +**Response** + +Sample `303` Response: + +The response is a redirect to the Stripe checkout session URL or to the cancel URL if an error occurs + +```text +Location: https://checkout.stripe.com/pay/cs_test_...#fidkdWxOYHwnP +``` + +```text +Location: https://mywebapp.com/cancel +``` + +### `POST /webhook` + +This endpoint is a webhook that handles Stripe event `checkout.session.completed`. It validates the incoming request using the Stripe's validateWebhook method. If the validation fails, a `401` response is sent. + +**Parameters** + +| Name | Description | Location | Type | Sample Value | +| ---- | ---------------------------- | -------- | ------ | --------------------------------------------------------------------- | +| None | Webhook payload from Stripe. | Body | Object | [See Stripe documentation](https://stripe.com/docs/api/events/object) | + +**Response** + +Sample `200` Response: + +In case of `checkout.session.completed` event, document for the order is created in Appwrite Database. + +```json +{ "success": true } +``` + +Sample `401` Response: + +```json +{ "success": false } +``` + +## ⚙️ Configuration + +| Setting | Value | +| ----------------- | ------------------------------ | +| Runtime | Node (18.0) | +| Entrypoint | `src/main.js` | +| Build Commands | `npm install && npm run setup` | +| Permissions | `any` | +| Timeout (Seconds) | 15 | + +> 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. + +## 🔒 Environment Variables + +### APPWRITE_API_KEY + +Your Appwrite project's API key. + +| Question | Answer | +| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| Required | Yes | +| Sample Value | `083d341ee48...` | +| Documentation | [Appwrite: Create an API key](https://appwrite.io/docs/keys#:~:text=To%20create%20a%20new%20API,scope%20to%20grant%20your%20application.) | + +### APPWRITE_ENDPOINT + +The endpoint where your Appwrite server is located. If not provided, it defaults to the Appwrite Cloud server: `https://cloud.appwrite.io/v1`. + +| Question | Answer | +| ------------ | ------------------------------ | +| Required | No | +| Sample Value | `https://cloud.appwrite.io/v1` | + +### APPWRITE_DATABASE_ID + +The ID of the database to store the orders. + +| Question | Answer | +| ------------ | ------ | +| Required | No | +| Sample Value | `main` | + +### APPWRITE_COLLECTION_ID + +The ID of the collection to store the orders. + +| Question | Answer | +| ------------ | -------- | +| Required | No | +| Sample Value | `orders` | + +### STRIPE_SECRET_KEY + +Secret for sending requests to the Stripe API. + +| Question | Answer | +| ------------- | ------------------------------------------------ | +| Required | Yes | +| Sample Value | `sk_test_51J...` | +| Documentation | [Stripe: API Keys](https://stripe.com/docs/keys) | + +### STRIPE_WEBHOOK_SECRET + +Secret used to validate the Stripe Webhook signature. + +| Question | Answer | +| ------------- | ---------------------------------------------------- | +| Required | Yes | +| Sample Value | `whsec_...` | +| Documentation | [Stripe: Webhooks](https://stripe.com/docs/webhooks) | diff --git a/node/payments-with-stripe/env.d.ts b/node/payments-with-stripe/env.d.ts new file mode 100644 index 00000000..0e8ce267 --- /dev/null +++ b/node/payments-with-stripe/env.d.ts @@ -0,0 +1,13 @@ +declare global { + namespace NodeJS { + interface ProcessEnv { + APPWRITE_ENDPOINT?: string; + APPWRITE_FUNCTION_PROJECT_ID: string; + APPWRITE_API_KEY: string; + STRIPE_SECRET_KEY: string; + STRIPE_WEBHOOK_SECRET: string; + } + } +} + +export {}; diff --git a/node/payments-with-stripe/package-lock.json b/node/payments-with-stripe/package-lock.json new file mode 100644 index 00000000..142e269c --- /dev/null +++ b/node/payments-with-stripe/package-lock.json @@ -0,0 +1,259 @@ +{ + "name": "payments-with-stripe", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "payments-with-stripe", + "version": "1.0.0", + "dependencies": { + "node-appwrite": "^10.0.0", + "stripe": "^12.12.0" + }, + "devDependencies": { + "prettier": "^3.0.0", + "stripe-event-types": "^2.3.0" + } + }, + "node_modules/@types/node": { + "version": "20.3.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.1.tgz", + "integrity": "sha512-EhcH/wvidPy1WeML3TtYFGR83UzjxeWRen9V402T8aUGYsCHOmfoisV3ZSg03gAFIbLq8TnWOJ0f4cALtnSEUg==" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", + "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dependencies": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, + "node_modules/get-intrinsic": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", + "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", + "dependencies": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dependencies": { + "function-bind": "^1.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-appwrite": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-10.0.1.tgz", + "integrity": "sha512-J+tccrkJ83D7s1pT3HoBCfNnlA/e/nbBVFuVCJUl0/6oYZndVi5ssHjDWtCiIEESNusVck/8dkpVldMzZzf7Lw==", + "dependencies": { + "axios": "^1.4.0", + "form-data": "^4.0.0" + } + }, + "node_modules/object-inspect": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", + "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/prettier": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.0.tgz", + "integrity": "sha512-zBf5eHpwHOGPC47h0zrPyNn+eAEIdEzfywMoYn2XPi0P44Zp0tSq64rq0xAREh4auw2cJZHo9QUob+NqCQky4g==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/qs": { + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", + "integrity": "sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dependencies": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stripe": { + "version": "12.12.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-12.12.0.tgz", + "integrity": "sha512-aM9xfyDryiaf/qSWMtJaTMrlc/he3qyx3aVHMqOZqUiMdgTV6lt7tLpFrU0pG+QURm1LAP9GYZ+EcA17446YoQ==", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, + "node_modules/stripe-event-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/stripe-event-types/-/stripe-event-types-2.3.0.tgz", + "integrity": "sha512-4qh0wblfZBbdHn3IH9bbRrGZw+TJuIern6UjoG1+17grSyhIJFvrrqTn2ACE/wx3PE7ZLXNG5Qzo7BEXXkuyoA==", + "dev": true, + "peerDependencies": { + "stripe": ">=10.0.0" + } + } + } +} diff --git a/node/payments-with-stripe/package.json b/node/payments-with-stripe/package.json new file mode 100644 index 00000000..b6c8a09a --- /dev/null +++ b/node/payments-with-stripe/package.json @@ -0,0 +1,19 @@ +{ + "name": "payments-with-stripe", + "version": "1.0.0", + "description": "", + "main": "src/main.js", + "type": "module", + "scripts": { + "format": "prettier --write .", + "setup": "node src/setup.js" + }, + "dependencies": { + "node-appwrite": "^10.0.0", + "stripe": "^12.12.0" + }, + "devDependencies": { + "prettier": "^3.0.0", + "stripe-event-types": "^2.3.0" + } +} diff --git a/node/payments-with-stripe/src/appwrite.js b/node/payments-with-stripe/src/appwrite.js new file mode 100644 index 00000000..76fc4808 --- /dev/null +++ b/node/payments-with-stripe/src/appwrite.js @@ -0,0 +1,101 @@ +import { Client, Databases, ID, Permission, Role } from 'node-appwrite'; + +class AppwriteService { + constructor() { + const client = new Client(); + client + .setEndpoint( + process.env.APPWRITE_ENDPOINT ?? 'https://cloud.appwrite.io/v1' + ) + .setProject(process.env.APPWRITE_FUNCTION_PROJECT_ID) + .setKey(process.env.APPWRITE_API_KEY); + + this.databases = new Databases(client); + } + + /** + * @param {string} databaseId + * @param {string} collectionId + * @param {string} userId + * @param {string} orderId + * @returns {Promise} + */ + async createOrder(databaseId, collectionId, userId, orderId) { + await this.databases.createDocument( + databaseId, + collectionId, + ID.unique(), + { + userId, + orderId, + }, + [Permission.read(Role.user(userId))] + ); + } + + /** + * @param {string} databaseId + * @returns {Promise} + */ + async doesOrdersDatabaseExist(databaseId) { + try { + await this.databases.get(databaseId); + return true; + } catch (err) { + if (err.code !== 404) throw err; + return false; + } + } + + /** + * @param {string} databaseId + * @param {string} collectionId + * @returns {Promise} + */ + async setupOrdersDatabase(databaseId, collectionId) { + try { + await this.databases.create(databaseId, 'Orders Database'); + } catch (err) { + // If resource already exists, we can ignore the error + if (err.code !== 409) throw err; + } + + try { + await this.databases.createCollection( + databaseId, + collectionId, + 'Orders Collection', + undefined, + true + ); + } catch (err) { + if (err.code !== 409) throw err; + } + + try { + await this.databases.createStringAttribute( + databaseId, + collectionId, + 'userId', + 255, + true + ); + } catch (err) { + if (err.code !== 409) throw err; + } + + try { + await this.databases.createStringAttribute( + databaseId, + collectionId, + 'orderId', + 255, + true + ); + } catch (err) { + if (err.code !== 409) throw err; + } + } +} + +export default AppwriteService; diff --git a/node/payments-with-stripe/src/main.js b/node/payments-with-stripe/src/main.js new file mode 100644 index 00000000..0e65e962 --- /dev/null +++ b/node/payments-with-stripe/src/main.js @@ -0,0 +1,89 @@ +import StripeService from './stripe.js'; +import AppwriteService from './appwrite.js'; +import { getStaticFile, interpolate, throwIfMissing } from './utils.js'; + +export default async (context) => { + const { req, res, log, error } = context; + + throwIfMissing(process.env, [ + 'STRIPE_SECRET_KEY', + 'STRIPE_WEBHOOK_SECRET', + 'APPWRITE_API_KEY', + ]); + + const databaseId = process.env.APPWRITE_DATABASE_ID ?? 'orders'; + const collectionId = process.env.APPWRITE_COLLECTION_ID ?? 'orders'; + + if (req.method === 'GET') { + const html = interpolate(getStaticFile('index.html'), { + APPWRITE_ENDPOINT: + process.env.APPWRITE_ENDPOINT ?? 'https://cloud.appwrite.io/v1', + APPWRITE_FUNCTION_PROJECT_ID: process.env.APPWRITE_FUNCTION_PROJECT_ID, + APPWRITE_FUNCTION_ID: process.env.APPWRITE_FUNCTION_ID, + APPWRITE_DATABASE_ID: databaseId, + APPWRITE_COLLECTION_ID: collectionId, + }); + + return res.send(html, 200, { 'Content-Type': 'text/html; charset=utf-8' }); + } + + const appwrite = new AppwriteService(); + const stripe = new StripeService(); + + switch (req.path) { + case '/checkout': + const fallbackUrl = req.scheme + '://' + req.headers['host'] + '/'; + + const successUrl = req.body?.successUrl ?? fallbackUrl; + const failureUrl = req.body?.failureUrl ?? fallbackUrl; + + const userId = req.headers['x-appwrite-user-id']; + if (!userId) { + error('User ID not found in request.'); + return res.redirect(failureUrl, 303); + } + + const session = await stripe.checkoutPayment( + context, + userId, + successUrl, + failureUrl + ); + if (!session) { + error('Failed to create Stripe checkout session.'); + return res.redirect(failureUrl, 303); + } + + context.log('Session:'); + context.log(session); + + log(`Created Stripe checkout session for user ${userId}.`); + return res.redirect(session.url, 303); + + case '/webhook': + const event = stripe.validateWebhook(context, req); + if (!event) { + return res.json({ success: false }, 401); + } + + context.log('Event:'); + context.log(event); + + if (event.type === 'checkout.session.completed') { + const session = event.data.object; + const userId = session.metadata.userId; + const orderId = session.id; + + await appwrite.createOrder(databaseId, collectionId, userId, orderId); + log( + `Created order document for user ${userId} with Stripe order ID ${orderId}` + ); + return res.json({ success: true }); + } + + return res.json({ success: true }); + + default: + return res.send('Not Found', 404); + } +}; diff --git a/node/payments-with-stripe/src/setup.js b/node/payments-with-stripe/src/setup.js new file mode 100644 index 00000000..21c69760 --- /dev/null +++ b/node/payments-with-stripe/src/setup.js @@ -0,0 +1,23 @@ +import AppwriteService from './appwrite.js'; +import { throwIfMissing } from './utils.js'; + +async function setup() { + throwIfMissing(process.env, ['APPWRITE_API_KEY']); + + const databaseId = process.env.APPWRITE_DATABASE_ID ?? 'orders'; + const collectionId = process.env.APPWRITE_COLLECTION_ID ?? 'orders'; + + console.log('Executing setup script...'); + + const appwrite = new AppwriteService(); + + if (await appwrite.doesOrdersDatabaseExist(databaseId)) { + console.log(`Database exists.`); + return; + } + + await appwrite.setupOrdersDatabase(databaseId, collectionId); + console.log(`Database created.`); +} + +setup(); diff --git a/node/payments-with-stripe/src/stripe.js b/node/payments-with-stripe/src/stripe.js new file mode 100644 index 00000000..f817d2bc --- /dev/null +++ b/node/payments-with-stripe/src/stripe.js @@ -0,0 +1,67 @@ +/// + +import stripe from 'stripe'; + +class StripeService { + constructor() { + // Note: stripe cjs API types are faulty + /** @type {import('stripe').Stripe} */ + // @ts-ignore + this.client = stripe(process.env.STRIPE_SECRET_KEY); + } + + /** + * @param {string} userId + * @param {string} successUrl + * @param {string} failureUrl + */ + async checkoutPayment(context, userId, successUrl, failureUrl) { + /** @type {import('stripe').Stripe.Checkout.SessionCreateParams.LineItem} */ + const lineItem = { + price_data: { + unit_amount: 1000, // $10.00 + currency: 'usd', + product_data: { + name: 'Product', + }, + }, + quantity: 1, + }; + + try { + return await this.client.checkout.sessions.create({ + payment_method_types: ['card'], + line_items: [lineItem], + success_url: successUrl, + cancel_url: failureUrl, + client_reference_id: userId, + metadata: { + userId, + }, + mode: 'payment', + }); + } catch (err) { + context.error(err); + return null; + } + } + + /** + * @returns {import("stripe").Stripe.DiscriminatedEvent | null} + */ + validateWebhook(context, req) { + try { + const event = this.client.webhooks.constructEvent( + req.bodyRaw, + req.headers['stripe-signature'], + process.env.STRIPE_WEBHOOK_SECRET + ); + return /** @type {import("stripe").Stripe.DiscriminatedEvent} */ (event); + } catch (err) { + context.error(err); + return null; + } + } +} + +export default StripeService; diff --git a/node/payments-with-stripe/src/utils.js b/node/payments-with-stripe/src/utils.js new file mode 100644 index 00000000..1a8951bd --- /dev/null +++ b/node/payments-with-stripe/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-stripe/static/index.html b/node/payments-with-stripe/static/index.html new file mode 100644 index 00000000..cbde0887 --- /dev/null +++ b/node/payments-with-stripe/static/index.html @@ -0,0 +1,206 @@ + + + + + + + Stripe Payments Demo + + + + + + + +
+
+
+
+

Stripe Payments Demo

+ +
+

+ Use this demo to create Stripe payment. Once paid, document is + created in Appwrite Database. You can then use this to ship + packages, send PDF books, give access to video courses, or anything + else your business needs. +

+
+
+
+
+ + + +
+
+
+ + + + + diff --git a/node/subscriptions-with-stripe/.gitignore b/node/subscriptions-with-stripe/.gitignore new file mode 100644 index 00000000..6a7d6d8e --- /dev/null +++ b/node/subscriptions-with-stripe/.gitignore @@ -0,0 +1,130 @@ +# 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.* \ No newline at end of file diff --git a/node/subscriptions-with-stripe/.prettierrc.json b/node/subscriptions-with-stripe/.prettierrc.json new file mode 100644 index 00000000..0a725205 --- /dev/null +++ b/node/subscriptions-with-stripe/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": true, + "singleQuote": true +} diff --git a/node/subscriptions-with-stripe/README.md b/node/subscriptions-with-stripe/README.md new file mode 100644 index 00000000..bfa8af8a --- /dev/null +++ b/node/subscriptions-with-stripe/README.md @@ -0,0 +1,112 @@ +# 💳 Node.js Stripe Subscriptions Function + +Receive recurring card payments and grant subscribers extra permissions. + +## 🧰 Usage + +### `POST /subscribe` + +This endpoint initiates a Stripe checkout session for a subscription. The user ID is fetched from the headers of the request. If the user ID is not found or a Stripe checkout session could not be created, the request will be redirected to a cancel 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 redirect to after a successful payment. | Body | String | https://example.com/success | +| failureUrl | The URL to redirect to after a cancelled payment attempt. | Body | String | https://example.com/failure | + +**Response** + +Sample `303` Response: + +The response is a redirect to the Stripe checkout session URL or to the cancel URL if an error occurs + +```text +Location: https://checkout.stripe.com/pay/cs_test_...#fidkdWxOYHwnP +``` + +```text +Location: https://mywebapp.com/cancel +``` + +### `POST /webhook` + +This endpoint is a webhook that handles two types of events from Stripe: `customer.subscription.created` and `customer.subscription.deleted`. It validates the incoming request using the Stripe's validateWebhook method. If the validation fails, a `401` response is sent. + +**Parameters** + +| Name | Description | Location | Type | Sample Value | +| ---- | ---------------------------- | -------- | ------ | --------------------------------------------------------------------- | +| None | Webhook payload from Stripe. | Body | Object | [See Stripe documentation](https://stripe.com/docs/api/events/object) | + +**Response** + +Sample `200` Response: + +In case of `customer.subscription.created` event, it gives user `subscriber` label. +In case of `customer.subscription.deleted` event, it takes `subscriber` label away from user. + +```json +{ "success": true } +``` + +Sample `401` Response: + +```json +{ "success": false } +``` + +## ⚙️ Configuration + +| Setting | Value | +| ----------------- | ------------- | +| Runtime | Node (18.0) | +| Entrypoint | `src/main.js` | +| Build Commands | `npm install` | +| Permissions | `any` | +| Timeout (Seconds) | 15 | + +> 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. + +## 🔒 Environment Variables + +### APPWRITE_API_KEY + +Your Appwrite project's API key. + +| Question | Answer | +| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| Required | Yes | +| Sample Value | `083d341ee48...` | +| Documentation | [Appwrite: Create an API key](https://appwrite.io/docs/keys#:~:text=To%20create%20a%20new%20API,scope%20to%20grant%20your%20application.) | + +### APPWRITE_ENDPOINT + +The endpoint where your Appwrite server is located. If not provided, it defaults to the Appwrite Cloud server: `https://cloud.appwrite.io/v1`. + +| Question | Answer | +| ------------ | ------------------------------ | +| Required | No | +| Sample Value | `https://cloud.appwrite.io/v1` | + +### STRIPE_SECRET_KEY + +Secret for sending requests to the Stripe API. + +| Question | Answer | +| ------------- | ------------------------------------------------ | +| Required | Yes | +| Sample Value | `sk_test_51J...` | +| Documentation | [Stripe: API Keys](https://stripe.com/docs/keys) | + +### STRIPE_WEBHOOK_SECRET + +Secret used to validate the Stripe Webhook signature. + +| Question | Answer | +| ------------- | ---------------------------------------------------- | +| Required | Yes | +| Sample Value | `whsec_...` | +| Documentation | [Stripe: Webhooks](https://stripe.com/docs/webhooks) | diff --git a/node/subscriptions-with-stripe/env.d.ts b/node/subscriptions-with-stripe/env.d.ts new file mode 100644 index 00000000..0e8ce267 --- /dev/null +++ b/node/subscriptions-with-stripe/env.d.ts @@ -0,0 +1,13 @@ +declare global { + namespace NodeJS { + interface ProcessEnv { + APPWRITE_ENDPOINT?: string; + APPWRITE_FUNCTION_PROJECT_ID: string; + APPWRITE_API_KEY: string; + STRIPE_SECRET_KEY: string; + STRIPE_WEBHOOK_SECRET: string; + } + } +} + +export {}; diff --git a/node/subscriptions-with-stripe/package-lock.json b/node/subscriptions-with-stripe/package-lock.json new file mode 100644 index 00000000..5f3e1c59 --- /dev/null +++ b/node/subscriptions-with-stripe/package-lock.json @@ -0,0 +1,259 @@ +{ + "name": "subscriptions-with-stripe", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "subscriptions-with-stripe", + "version": "1.0.0", + "dependencies": { + "node-appwrite": "^10.0.0", + "stripe": "^12.12.0" + }, + "devDependencies": { + "prettier": "^3.0.0", + "stripe-event-types": "^2.3.0" + } + }, + "node_modules/@types/node": { + "version": "20.3.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.1.tgz", + "integrity": "sha512-EhcH/wvidPy1WeML3TtYFGR83UzjxeWRen9V402T8aUGYsCHOmfoisV3ZSg03gAFIbLq8TnWOJ0f4cALtnSEUg==" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", + "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dependencies": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, + "node_modules/get-intrinsic": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", + "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", + "dependencies": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dependencies": { + "function-bind": "^1.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-appwrite": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-10.0.1.tgz", + "integrity": "sha512-J+tccrkJ83D7s1pT3HoBCfNnlA/e/nbBVFuVCJUl0/6oYZndVi5ssHjDWtCiIEESNusVck/8dkpVldMzZzf7Lw==", + "dependencies": { + "axios": "^1.4.0", + "form-data": "^4.0.0" + } + }, + "node_modules/object-inspect": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", + "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/prettier": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.0.tgz", + "integrity": "sha512-zBf5eHpwHOGPC47h0zrPyNn+eAEIdEzfywMoYn2XPi0P44Zp0tSq64rq0xAREh4auw2cJZHo9QUob+NqCQky4g==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/qs": { + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", + "integrity": "sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dependencies": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stripe": { + "version": "12.12.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-12.12.0.tgz", + "integrity": "sha512-aM9xfyDryiaf/qSWMtJaTMrlc/he3qyx3aVHMqOZqUiMdgTV6lt7tLpFrU0pG+QURm1LAP9GYZ+EcA17446YoQ==", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, + "node_modules/stripe-event-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/stripe-event-types/-/stripe-event-types-2.3.0.tgz", + "integrity": "sha512-4qh0wblfZBbdHn3IH9bbRrGZw+TJuIern6UjoG1+17grSyhIJFvrrqTn2ACE/wx3PE7ZLXNG5Qzo7BEXXkuyoA==", + "dev": true, + "peerDependencies": { + "stripe": ">=10.0.0" + } + } + } +} diff --git a/node/subscriptions-with-stripe/package.json b/node/subscriptions-with-stripe/package.json new file mode 100644 index 00000000..7570e6a6 --- /dev/null +++ b/node/subscriptions-with-stripe/package.json @@ -0,0 +1,18 @@ +{ + "name": "subscriptions-with-stripe", + "version": "1.0.0", + "description": "", + "main": "src/main.js", + "type": "module", + "scripts": { + "format": "prettier --write ." + }, + "dependencies": { + "node-appwrite": "^10.0.0", + "stripe": "^12.12.0" + }, + "devDependencies": { + "prettier": "^3.0.0", + "stripe-event-types": "^2.3.0" + } +} diff --git a/node/subscriptions-with-stripe/src/appwrite.js b/node/subscriptions-with-stripe/src/appwrite.js new file mode 100644 index 00000000..23154805 --- /dev/null +++ b/node/subscriptions-with-stripe/src/appwrite.js @@ -0,0 +1,42 @@ +import { Client, Users } from 'node-appwrite'; + +const LabelsSubscriber = 'subscriber'; + +class AppwriteService { + constructor() { + const client = new Client(); + client + .setEndpoint( + process.env.APPWRITE_ENDPOINT ?? 'https://cloud.appwrite.io/v1' + ) + .setProject(process.env.APPWRITE_FUNCTION_PROJECT_ID) + .setKey(process.env.APPWRITE_API_KEY); + + this.users = new Users(client); + } + + /** + * @param {string} userId + * @returns {Promise} + */ + async deleteSubscription(userId) { + const labels = (await this.users.get(userId)).labels.filter( + (label) => label !== LabelsSubscriber + ); + + await this.users.updateLabels(userId, labels); + } + + /** + * @param {string} userId + * @returns {Promise} + */ + async createSubscription(userId) { + const labels = (await this.users.get(userId)).labels; + labels.push(LabelsSubscriber); + + await this.users.updateLabels(userId, labels); + } +} + +export default AppwriteService; diff --git a/node/subscriptions-with-stripe/src/main.js b/node/subscriptions-with-stripe/src/main.js new file mode 100644 index 00000000..623c3939 --- /dev/null +++ b/node/subscriptions-with-stripe/src/main.js @@ -0,0 +1,90 @@ +import StripeService from './stripe.js'; +import AppwriteService from './appwrite.js'; +import { getStaticFile, interpolate, throwIfMissing } from './utils.js'; + +export default async (context) => { + const { req, res, log, error } = context; + + throwIfMissing(process.env, [ + 'STRIPE_SECRET_KEY', + 'STRIPE_WEBHOOK_SECRET', + 'APPWRITE_API_KEY', + ]); + + if (req.method === 'GET') { + const html = interpolate(getStaticFile('index.html'), { + APPWRITE_ENDPOINT: + process.env.APPWRITE_ENDPOINT ?? 'https://cloud.appwrite.io/v1', + APPWRITE_FUNCTION_PROJECT_ID: process.env.APPWRITE_FUNCTION_PROJECT_ID, + APPWRITE_FUNCTION_ID: process.env.APPWRITE_FUNCTION_ID, + }); + + return res.send(html, 200, { 'Content-Type': 'text/html; charset=utf-8' }); + } + + const appwrite = new AppwriteService(); + const stripe = new StripeService(); + + switch (req.path) { + case '/subscribe': + const fallbackUrl = req.scheme + '://' + req.headers['host'] + '/'; + + const successUrl = req.body?.successUrl ?? fallbackUrl; + const failureUrl = req.body?.failureUrl ?? fallbackUrl; + + const userId = req.headers['x-appwrite-user-id']; + if (!userId) { + error('User ID not found in request.'); + return res.redirect(failureUrl, 303); + } + + const session = await stripe.checkoutSubscription( + context, + userId, + successUrl, + failureUrl + ); + if (!session) { + error('Failed to create Stripe checkout session.'); + return res.redirect(failureUrl, 303); + } + + context.log('Session:'); + context.log(session); + + log(`Created Stripe checkout session for user ${userId}.`); + return res.redirect(session.url, 303); + + case '/webhook': + const event = stripe.validateWebhook(context, req); + if (!event) { + return res.json({ success: false }, 401); + } + + context.log('Event:'); + context.log(event); + + if (event.type === 'customer.subscription.created') { + const session = event.data.object; + const userId = session.metadata.userId; + + await appwrite.createSubscription(userId); + log(`Created subscription for user ${userId}`); + return res.json({ success: true }); + } + + if (event.type === 'customer.subscription.deleted') { + const session = event.data.object; + const userId = session.metadata.userId; + + await appwrite.deleteSubscription(userId); + log(`Deleted subscription for user ${userId}`); + return res.json({ success: true }); + } + + return res.json({ success: true }); + + default: + return res.send('Not Found', 404); + } +}; diff --git a/node/subscriptions-with-stripe/src/stripe.js b/node/subscriptions-with-stripe/src/stripe.js new file mode 100644 index 00000000..c02501db --- /dev/null +++ b/node/subscriptions-with-stripe/src/stripe.js @@ -0,0 +1,72 @@ +/// + +import stripe from 'stripe'; + +class StripeService { + constructor() { + // Note: stripe cjs API types are faulty + /** @type {import('stripe').Stripe} */ + // @ts-ignore + this.client = stripe(process.env.STRIPE_SECRET_KEY); + } + + /** + * @param {string} userId + * @param {string} successUrl + * @param {string} failureUrl + */ + async checkoutSubscription(context, userId, successUrl, failureUrl) { + /** @type {import('stripe').Stripe.Checkout.SessionCreateParams.LineItem} */ + const lineItem = { + price_data: { + unit_amount: 1000, // $10.00 + currency: 'usd', + recurring: { + interval: 'month', + }, + product_data: { + name: 'Premium Subscription', + }, + }, + quantity: 1, + }; + + try { + return await this.client.checkout.sessions.create({ + payment_method_types: ['card'], + line_items: [lineItem], + success_url: successUrl, + cancel_url: failureUrl, + client_reference_id: userId, + subscription_data: { + metadata: { + userId, + }, + }, + mode: 'subscription', + }); + } catch (err) { + context.error(err); + return null; + } + } + + /** + * @returns {import("stripe").Stripe.DiscriminatedEvent | null} + */ + validateWebhook(context, req) { + try { + const event = this.client.webhooks.constructEvent( + req.bodyRaw, + req.headers['stripe-signature'], + process.env.STRIPE_WEBHOOK_SECRET + ); + return /** @type {import("stripe").Stripe.DiscriminatedEvent} */ (event); + } catch (err) { + context.error(err); + return null; + } + } +} + +export default StripeService; diff --git a/node/subscriptions-with-stripe/src/utils.js b/node/subscriptions-with-stripe/src/utils.js new file mode 100644 index 00000000..1a8951bd --- /dev/null +++ b/node/subscriptions-with-stripe/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-stripe/static/index.html b/node/subscriptions-with-stripe/static/index.html new file mode 100644 index 00000000..a065afc6 --- /dev/null +++ b/node/subscriptions-with-stripe/static/index.html @@ -0,0 +1,174 @@ + + + + + + + Stripe Subscriptions Demo + + + + + + + +
+
+
+
+

Stripe Subscriptions Demo

+ +
+

+ Use this demo to create Stripe subscription. Once subscribed, label + is added to an user. You can then use this to give permissions to + subscribers only. +

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