From 31f3c4fef7fb83cfcc332ba8d7719f299343b738 Mon Sep 17 00:00:00 2001 From: MarconLP <13001502+MarconLP@users.noreply.github.com> Date: Sun, 16 Apr 2023 13:05:18 +0200 Subject: [PATCH] add stripe checkout --- package-lock.json | 339 ++++++++++++++++++++++++-- package.json | 6 +- prisma/schema.prisma | 43 +++- src/components/Checkout.tsx | 94 +++++++ src/components/ProfileMenu.tsx | 72 ++++++ src/components/VideoMoreMenu.tsx | 2 +- src/env.mjs | 12 +- src/pages/api/webhooks/stripe.ts | 110 +++++++++ src/pages/videos.tsx | 63 +++-- src/server/api/root.ts | 2 + src/server/api/routers/stripe.ts | 80 ++++++ src/server/api/trpc.ts | 9 + src/server/auth.ts | 9 +- src/server/stripe-webhook-handlers.ts | 123 ++++++++++ src/server/stripe.ts | 5 + 15 files changed, 900 insertions(+), 69 deletions(-) create mode 100644 src/components/Checkout.tsx create mode 100644 src/components/ProfileMenu.tsx create mode 100644 src/pages/api/webhooks/stripe.ts create mode 100644 src/server/api/routers/stripe.ts create mode 100644 src/server/stripe-webhook-handlers.ts create mode 100644 src/server/stripe.ts diff --git a/package-lock.json b/package-lock.json index 81482d5..0ecb9d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,18 +25,22 @@ "axios": "^1.3.5", "dayjs": "^1.11.7", "file-saver": "^2.0.5", - "next": "^13.2.4", + "micro": "^10.0.1", + "micro-cors": "^0.1.1", + "next": "^13.3.0", "next-auth": "^4.21.0", "react": "18.2.0", "react-dom": "18.2.0", "react-player": "^2.12.0", "react-popper": "^2.3.0", + "stripe": "^12.1.1", "superjson": "1.12.2", "zod": "^3.21.4" }, "devDependencies": { "@playwright/test": "^1.32.3", "@types/eslint": "^8.21.3", + "@types/micro-cors": "^0.1.3", "@types/node": "^18.15.5", "@types/prettier": "^2.7.2", "@types/react": "^18.0.28", @@ -2336,11 +2340,28 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/micro": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/@types/micro/-/micro-7.3.7.tgz", + "integrity": "sha512-MFsX7eCj0Tg3TtphOQvANNvNtFpya+s/rYOCdV6o+DFjOQPFi2EVRbBALjbbgZTXUaJP1Q281MJiJOD40d0UxQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/micro-cors": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@types/micro-cors/-/micro-cors-0.1.3.tgz", + "integrity": "sha512-f4aMXqEw9YjfdKX87m1LecvZJ2Mhz5maIHXjIvm5K6OTPe9auaTQwaFk4OZYS9zY6zdzfxqs2cEmwJAF7C9Y8A==", + "dev": true, + "dependencies": { + "@types/micro": "^7.3.7" + } + }, "node_modules/@types/node": { "version": "18.15.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.11.tgz", - "integrity": "sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==", - "dev": true + "integrity": "sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==" }, "node_modules/@types/prettier": { "version": "2.7.2", @@ -2944,11 +2965,18 @@ "node": ">=10.16.0" } }, + "node_modules/bytes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", + "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==", + "engines": { + "node": ">= 0.8" + } + }, "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==", - "dev": true, "dependencies": { "function-bind": "^1.1.1", "get-intrinsic": "^1.0.2" @@ -3098,6 +3126,14 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", @@ -3255,6 +3291,14 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -4162,8 +4206,7 @@ "node_modules/function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, "node_modules/function.prototype.name": { "version": "1.1.5", @@ -4196,7 +4239,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz", "integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==", - "dev": true, "dependencies": { "function-bind": "^1.1.1", "has": "^1.0.3", @@ -4353,7 +4395,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, "dependencies": { "function-bind": "^1.1.1" }, @@ -4407,7 +4448,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -4430,6 +4470,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/http-errors": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", + "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", @@ -4477,8 +4543,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/internal-slot": { "version": "1.0.5", @@ -5034,6 +5099,35 @@ "node": ">= 8" } }, + "node_modules/micro": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/micro/-/micro-10.0.1.tgz", + "integrity": "sha512-9uwZSsUrqf6+4FLLpiPj5TRWQv5w5uJrJwsx1LR/TjqvQmKC1XnGQ9OHrFwR3cbZ46YqPqxO/XJCOpWnqMPw2Q==", + "dependencies": { + "arg": "4.1.0", + "content-type": "1.0.4", + "raw-body": "2.4.1" + }, + "bin": { + "micro": "dist/src/bin/micro.js" + }, + "engines": { + "node": ">= 16.0.0" + } + }, + "node_modules/micro-cors": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/micro-cors/-/micro-cors-0.1.1.tgz", + "integrity": "sha512-6WqIahA5sbQR1Gjexp1VuWGFDKbZZleJb/gy1khNGk18a6iN1FdTcr3Q8twaxkV5H94RjxIBjirYbWCehpMBFw==", + "engines": { + "node": ">=6" + } + }, + "node_modules/micro/node_modules/arg": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.0.tgz", + "integrity": "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==" + }, "node_modules/micromatch": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", @@ -5284,7 +5378,6 @@ "version": "1.12.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -5851,6 +5944,20 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.1.tgz", + "integrity": "sha512-0wsrzgTz/kAVIeuxSjnpGC56rzYtr6JT/2BwEvMaPhFIoYa1aGO8LbzuU1R0uUYQkLpWBTOj0l/CLAJB64J6nQ==", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5883,6 +5990,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/raw-body": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.1.tgz", + "integrity": "sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==", + "dependencies": { + "bytes": "3.1.0", + "http-errors": "1.7.3", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/react": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", @@ -6084,6 +6205,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, "node_modules/scheduler": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", @@ -6107,6 +6233,11 @@ "node": ">=10" } }, + "node_modules/setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -6132,7 +6263,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dev": true, "dependencies": { "call-bind": "^1.0.0", "get-intrinsic": "^1.0.2", @@ -6178,6 +6308,14 @@ "source-map": "^0.6.0" } }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", @@ -6295,6 +6433,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-12.1.1.tgz", + "integrity": "sha512-vn74vXtZeJx18oGzA0AhL818euhLF/juCgkKrJfAS1Y0bp5/EzQKPuc/75qQUvY43nNGIkgOVb3kUBuyoeqEkA==", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, "node_modules/strnum": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", @@ -6553,6 +6703,14 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", + "engines": { + "node": ">=0.6" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -6680,6 +6838,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/update-browserslist-db": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz", @@ -8523,11 +8689,28 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "@types/micro": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/@types/micro/-/micro-7.3.7.tgz", + "integrity": "sha512-MFsX7eCj0Tg3TtphOQvANNvNtFpya+s/rYOCdV6o+DFjOQPFi2EVRbBALjbbgZTXUaJP1Q281MJiJOD40d0UxQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/micro-cors": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@types/micro-cors/-/micro-cors-0.1.3.tgz", + "integrity": "sha512-f4aMXqEw9YjfdKX87m1LecvZJ2Mhz5maIHXjIvm5K6OTPe9auaTQwaFk4OZYS9zY6zdzfxqs2cEmwJAF7C9Y8A==", + "dev": true, + "requires": { + "@types/micro": "^7.3.7" + } + }, "@types/node": { "version": "18.15.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.11.tgz", - "integrity": "sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==", - "dev": true + "integrity": "sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==" }, "@types/prettier": { "version": "2.7.2", @@ -8941,11 +9124,15 @@ "streamsearch": "^1.1.0" } }, + "bytes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", + "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" + }, "call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dev": true, "requires": { "function-bind": "^1.1.1", "get-intrinsic": "^1.0.2" @@ -9045,6 +9232,11 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" + }, "cookie": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", @@ -9158,6 +9350,11 @@ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==" + }, "didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -9856,8 +10053,7 @@ "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, "function.prototype.name": { "version": "1.1.5", @@ -9881,7 +10077,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz", "integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==", - "dev": true, "requires": { "function-bind": "^1.1.1", "has": "^1.0.3", @@ -9996,7 +10191,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, "requires": { "function-bind": "^1.1.1" } @@ -10031,8 +10225,7 @@ "has-symbols": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" }, "has-tostringtag": { "version": "1.0.0", @@ -10043,6 +10236,26 @@ "has-symbols": "^1.0.2" } }, + "http-errors": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", + "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + } + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, "ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", @@ -10078,8 +10291,7 @@ "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "internal-slot": { "version": "1.0.5", @@ -10472,6 +10684,28 @@ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true }, + "micro": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/micro/-/micro-10.0.1.tgz", + "integrity": "sha512-9uwZSsUrqf6+4FLLpiPj5TRWQv5w5uJrJwsx1LR/TjqvQmKC1XnGQ9OHrFwR3cbZ46YqPqxO/XJCOpWnqMPw2Q==", + "requires": { + "arg": "4.1.0", + "content-type": "1.0.4", + "raw-body": "2.4.1" + }, + "dependencies": { + "arg": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.0.tgz", + "integrity": "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==" + } + } + }, + "micro-cors": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/micro-cors/-/micro-cors-0.1.1.tgz", + "integrity": "sha512-6WqIahA5sbQR1Gjexp1VuWGFDKbZZleJb/gy1khNGk18a6iN1FdTcr3Q8twaxkV5H94RjxIBjirYbWCehpMBFw==" + }, "micromatch": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", @@ -10630,8 +10864,7 @@ "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==", - "dev": true + "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==" }, "object-is": { "version": "1.1.5", @@ -10963,6 +11196,14 @@ "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", "dev": true }, + "qs": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.1.tgz", + "integrity": "sha512-0wsrzgTz/kAVIeuxSjnpGC56rzYtr6JT/2BwEvMaPhFIoYa1aGO8LbzuU1R0uUYQkLpWBTOj0l/CLAJB64J6nQ==", + "requires": { + "side-channel": "^1.0.4" + } + }, "queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -10975,6 +11216,17 @@ "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", "dev": true }, + "raw-body": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.1.tgz", + "integrity": "sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==", + "requires": { + "bytes": "3.1.0", + "http-errors": "1.7.3", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + }, "react": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", @@ -11115,6 +11367,11 @@ "is-regex": "^1.1.4" } }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, "scheduler": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", @@ -11132,6 +11389,11 @@ "lru-cache": "^6.0.0" } }, + "setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" + }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -11151,7 +11413,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dev": true, "requires": { "call-bind": "^1.0.0", "get-intrinsic": "^1.0.2", @@ -11185,6 +11446,11 @@ "source-map": "^0.6.0" } }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==" + }, "stop-iteration-iterator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", @@ -11269,6 +11535,15 @@ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true }, + "stripe": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-12.1.1.tgz", + "integrity": "sha512-vn74vXtZeJx18oGzA0AhL818euhLF/juCgkKrJfAS1Y0bp5/EzQKPuc/75qQUvY43nNGIkgOVb3kUBuyoeqEkA==", + "requires": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + } + }, "strnum": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", @@ -11445,6 +11720,11 @@ "is-number": "^7.0.0" } }, + "toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" + }, "ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -11541,6 +11821,11 @@ "which-boxed-primitive": "^1.0.2" } }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + }, "update-browserslist-db": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz", diff --git a/package.json b/package.json index 87342fe..fd005b2 100644 --- a/package.json +++ b/package.json @@ -31,18 +31,22 @@ "axios": "^1.3.5", "dayjs": "^1.11.7", "file-saver": "^2.0.5", - "next": "^13.2.4", + "micro": "^10.0.1", + "micro-cors": "^0.1.1", + "next": "^13.3.0", "next-auth": "^4.21.0", "react": "18.2.0", "react-dom": "18.2.0", "react-player": "^2.12.0", "react-popper": "^2.3.0", + "stripe": "^12.1.1", "superjson": "1.12.2", "zod": "^3.21.4" }, "devDependencies": { "@playwright/test": "^1.32.3", "@types/eslint": "^8.21.3", + "@types/micro-cors": "^0.1.3", "@types/node": "^18.15.5", "@types/prettier": "^2.7.2", "@types/react": "^18.0.28", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8d53f91..b32993d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -58,14 +58,41 @@ model Session { } model User { - id String @id @default(cuid()) - name String? - email String? @unique - emailVerified DateTime? - image String? - accounts Account[] - sessions Session[] - videos Video[] + id String @id @default(cuid()) + name String? + email String? @unique + emailVerified DateTime? + image String? + accounts Account[] + sessions Session[] + videos Video[] + stripeCustomerId String? + stripeSubscriptionId String? + stripeSubscriptionStatus StripeSubscriptionStatus? +} + +enum StripeSubscriptionStatus { + incomplete + incomplete_expired + trialing + active + past_due + canceled + unpaid + paused +} + +model StripeEvent { + id String @id @unique + api_version String? + data Json + request Json? + type String + object String + account String? + created DateTime + livemode Boolean + pending_webhooks Int } model VerificationToken { diff --git a/src/components/Checkout.tsx b/src/components/Checkout.tsx new file mode 100644 index 0000000..629698d --- /dev/null +++ b/src/components/Checkout.tsx @@ -0,0 +1,94 @@ +import { useRouter } from "next/router"; +import { api } from "~/utils/api"; + +export default function Checkout() { + const { mutateAsync: createCheckoutSession } = + api.stripe.createCheckoutSession.useMutation(); + const { push } = useRouter(); + + return ( +
+
+
+

+ Simple no-tricks pricing +

+
+
+
+

+ Pro plan +

+

+ Record and share unlimited videos. With this plan, you'll + have access to all powerful features that help you create and + share high-quality videos with ease. +

+
+

+ What’s included +

+
+
+
    + {[ + "Versatile screen recording: Tab, desktop, any app, camera.", + "Secure video sharing: Automatic link expiry.", + "Powerful annotation tools: Text, drawing, arrows.", + "Effortless editing: Trimming, removal of unwanted sections.", + ].map((x) => ( +
  • + + {x} +
  • + ))} +
+
+
+
+
+

+ + $5 + + + USD / mo + +

+ +

+ Invoices and receipts available for easy company reimbursement +

+
+
+
+
+
+
+ ); +} diff --git a/src/components/ProfileMenu.tsx b/src/components/ProfileMenu.tsx new file mode 100644 index 0000000..e71c6c6 --- /dev/null +++ b/src/components/ProfileMenu.tsx @@ -0,0 +1,72 @@ +import { Menu, Transition } from "@headlessui/react"; +import { Fragment } from "react"; +import { signOut } from "next-auth/react"; +import { useRouter } from "next/router"; +import { api } from "~/utils/api"; + +export default function ProfileMenu() { + const { mutateAsync: createBillingPortalSession } = + api.stripe.createBillingPortalSession.useMutation(); + const { push } = useRouter(); + + return ( + + + Open user menu + + + + +
+ + {({ active }) => ( +
{ + void createBillingPortalSession().then( + ({ billingPortalUrl }) => { + if (billingPortalUrl) { + void push(billingPortalUrl); + } + } + ); + }} + className={`mx-2 flex h-8 w-40 cursor-pointer flex-row content-center rounded-md p-2 ${ + active ? "bg-gray-100" : "" + }`} + > +

+ Billing settings +

+
+ )} +
+ + {({ active }) => ( +
void signOut()} + className={`mx-2 flex h-8 w-40 cursor-pointer flex-row content-center rounded-md p-2 ${ + active ? "bg-gray-100" : "" + }`} + > +

Sign out

+
+ )} +
+
+
+
+
+ ); +} diff --git a/src/components/VideoMoreMenu.tsx b/src/components/VideoMoreMenu.tsx index cc39fb0..e43c802 100644 --- a/src/components/VideoMoreMenu.tsx +++ b/src/components/VideoMoreMenu.tsx @@ -114,7 +114,7 @@ export default function VideoMoreMenu({ video }: Props) { leaveFrom="transform opacity-100 scale-100" leaveTo="transform opacity-0 scale-95" > - +
{items.map((item) => (
diff --git a/src/env.mjs b/src/env.mjs index 386140f..2e6d7d4 100644 --- a/src/env.mjs +++ b/src/env.mjs @@ -27,7 +27,10 @@ const server = z.object({ AWS_REGION: z.string(), AWS_KEY_ID: z.string(), AWS_ACCESS_KEY: z.string(), - AWS_BUCKET_NAME: z.string() + AWS_BUCKET_NAME: z.string(), + STRIPE_SECRET_KEY: z.string(), + STRIPE_WEBHOOK_SECRET: z.string(), + STRIPE_PRICE_ID: z.string() }); /** @@ -36,6 +39,7 @@ const server = z.object({ */ const client = z.object({ // NEXT_PUBLIC_CLIENTVAR: z.string().min(1), + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string() }); /** @@ -58,7 +62,11 @@ const processEnv = { AWS_REGION: process.env.AWS_REGION, AWS_KEY_ID: process.env.AWS_KEY_ID, AWS_ACCESS_KEY: process.env.AWS_ACCESS_KEY, - AWS_BUCKET_NAME: process.env.AWS_BUCKET_NAME + AWS_BUCKET_NAME: process.env.AWS_BUCKET_NAME, + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, + STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, + STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, + STRIPE_PRICE_ID: process.env.STRIPE_PRICE_ID }; // Don't touch the part below diff --git a/src/pages/api/webhooks/stripe.ts b/src/pages/api/webhooks/stripe.ts new file mode 100644 index 0000000..828bb9a --- /dev/null +++ b/src/pages/api/webhooks/stripe.ts @@ -0,0 +1,110 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { env } from "~/env.mjs"; +import { prisma } from "~/server/db"; +import type Stripe from "stripe"; +import { buffer } from "micro"; +import { + handleInvoicePaid, + handleSubscriptionCanceled, + handleSubscriptionCreatedOrUpdated, +} from "~/server/stripe-webhook-handlers"; +import { stripe } from "~/server/stripe"; + +// Stripe requires the raw body to construct the event. +export const config = { + api: { + bodyParser: false, + }, +}; + +const webhookSecret = env.STRIPE_WEBHOOK_SECRET; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method === "POST") { + const buf = await buffer(req); + const sig = req.headers["stripe-signature"]; + + let event: Stripe.Event; + + try { + event = stripe.webhooks.constructEvent(buf, sig as string, webhookSecret); + + // Handle the event + switch (event.type) { + case "invoice.paid": + // Used to provision services after the trial has ended. + // The status of the invoice will show up as paid. Store the status in your database to reference when a user accesses your service to avoid hitting rate limits. + await handleInvoicePaid({ + event, + stripe, + prisma, + }); + break; + case "customer.subscription.created": + // Used to provision services as they are added to a subscription. + await handleSubscriptionCreatedOrUpdated({ + event, + prisma, + }); + break; + case "customer.subscription.updated": + // Used to provision services as they are updated. + await handleSubscriptionCreatedOrUpdated({ + event, + prisma, + }); + break; + case "invoice.payment_failed": + // If the payment fails or the customer does not have a valid payment method, + // an invoice.payment_failed event is sent, the subscription becomes past_due. + // Use this webhook to notify your user that their payment has + // failed and to retrieve new card details. + // Can also have Stripe send an email to the customer notifying them of the failure. See settings: https://dashboard.stripe.com/settings/billing/automatic + break; + case "customer.subscription.deleted": + // handle subscription cancelled automatically based + // upon your subscription settings. + await handleSubscriptionCanceled({ + event, + prisma, + }); + break; + default: + // Unexpected event type + } + + // record the event in the database + await prisma.stripeEvent.create({ + data: { + id: event.id, + type: event.type, + object: event.object, + api_version: event.api_version, + account: event.account, + created: new Date(event.created * 1000), // convert to milliseconds + data: { + object: event.data.object, + previous_attributes: event.data.previous_attributes, + }, + livemode: event.livemode, + pending_webhooks: event.pending_webhooks, + request: { + id: event.request?.id, + idempotency_key: event.request?.idempotency_key, + }, + }, + }); + + res.json({ received: true }); + } catch (err) { + res.status(400).send(err); + return; + } + } else { + res.setHeader("Allow", "POST"); + res.status(405).end("Method Not Allowed"); + } +} diff --git a/src/pages/videos.tsx b/src/pages/videos.tsx index fd9ddc5..40d5051 100644 --- a/src/pages/videos.tsx +++ b/src/pages/videos.tsx @@ -8,10 +8,12 @@ import { useRouter } from "next/router"; import Image from "next/image"; import VideoUploadModal from "~/components/VideoUploadModal"; import { getTime } from "~/utils/getTime"; +import Checkout from "~/components/Checkout"; +import ProfileMenu from "~/components/ProfileMenu"; const VideoList: NextPage = () => { const router = useRouter(); - const { status } = useSession(); + const { status, data: session } = useSession(); const { data: videos, isLoading } = api.video.getAll.useQuery(); if (status === "unauthenticated") { @@ -28,37 +30,46 @@ const VideoList: NextPage = () => {
Screenity -
+
+ {status === "authenticated" && ( +
+ +
+ )}
-
- {videos && - videos.map(({ title, id, createdAt }) => ( - - ))} + {session?.user.stripeSubscriptionStatus === null ? ( + + ) : ( +
+ {videos && + videos.map(({ title, id, createdAt }) => ( + + ))} - {isLoading ? ( - <> - - - - - - ) : null} + {isLoading ? ( + <> + + + + + + ) : null} - {videos && videos?.length <= 0 ? ( -
- You do not have any recordings. -
- ) : null} -
+ {videos && videos?.length <= 0 ? ( +
+ You do not have any recordings. +
+ ) : null} +
+ )}
diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 9cc2ab5..1f6bea9 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -1,6 +1,7 @@ import { createTRPCRouter } from "~/server/api/trpc"; import { exampleRouter } from "~/server/api/routers/example"; import { videoRouter } from "~/server/api/routers/video"; +import { stripeRouter } from "~/server/api/routers/stripe"; /** * This is the primary router for your server. @@ -10,6 +11,7 @@ import { videoRouter } from "~/server/api/routers/video"; export const appRouter = createTRPCRouter({ example: exampleRouter, video: videoRouter, + stripe: stripeRouter, }); // export type definition of API diff --git a/src/server/api/routers/stripe.ts b/src/server/api/routers/stripe.ts new file mode 100644 index 0000000..2caa590 --- /dev/null +++ b/src/server/api/routers/stripe.ts @@ -0,0 +1,80 @@ +import { env } from "~/env.mjs"; +import { getOrCreateStripeCustomerIdForUser } from "~/server/stripe-webhook-handlers"; +import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; + +export const stripeRouter = createTRPCRouter({ + createCheckoutSession: protectedProcedure.mutation(async ({ ctx }) => { + const { stripe, session, prisma, req } = ctx; + + const customerId = await getOrCreateStripeCustomerIdForUser({ + prisma, + stripe, + userId: session.user?.id, + }); + + if (!customerId) { + throw new Error("Could not create customer"); + } + + const baseUrl = + env.NODE_ENV === "development" + ? `http://${req.headers.host ?? "localhost:3000"}` + : `https://${req.headers.host ?? env.NEXTAUTH_URL}`; + + const checkoutSession = await stripe.checkout.sessions.create({ + customer: customerId, + client_reference_id: session.user?.id, + payment_method_types: ["card"], + mode: "subscription", + line_items: [ + { + price: env.STRIPE_PRICE_ID, + quantity: 1, + }, + ], + success_url: `${baseUrl}/videos?checkoutSuccess=true`, + cancel_url: `${baseUrl}/videos?checkoutCanceled=true`, + subscription_data: { + metadata: { + userId: session.user?.id, + }, + }, + }); + + if (!checkoutSession) { + throw new Error("Could not create checkout session"); + } + + return { checkoutUrl: checkoutSession.url }; + }), + createBillingPortalSession: protectedProcedure.mutation(async ({ ctx }) => { + const { stripe, session, prisma, req } = ctx; + + const customerId = await getOrCreateStripeCustomerIdForUser({ + prisma, + stripe, + userId: session.user?.id, + }); + + if (!customerId) { + throw new Error("Could not create customer"); + } + + const baseUrl = + env.NODE_ENV === "development" + ? `http://${req.headers.host ?? "localhost:3000"}` + : `https://${req.headers.host ?? env.NEXTAUTH_URL}`; + + const stripeBillingPortalSession = + await stripe.billingPortal.sessions.create({ + customer: customerId, + return_url: `${baseUrl}/videos`, + }); + + if (!stripeBillingPortalSession) { + throw new Error("Could not create billing portal session"); + } + + return { billingPortalUrl: stripeBillingPortalSession.url }; + }), +}); diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index 7a5fc7c..9224b68 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -22,6 +22,8 @@ import { prisma } from "~/server/db"; type CreateContextOptions = { session: Session | null; + req: NextApiRequest; + res: NextApiResponse; }; /** @@ -39,6 +41,9 @@ const createInnerTRPCContext = (opts: CreateContextOptions) => { session: opts.session, prisma, s3, + stripe, + req: opts.req, + res: opts.res, }; }; @@ -56,6 +61,8 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => { return createInnerTRPCContext({ session, + req, + res, }); }; @@ -70,6 +77,8 @@ import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; import { ZodError } from "zod"; import { s3 } from "~/server/aws/s3"; +import { stripe } from "~/server/stripe"; +import { type NextApiRequest, type NextApiResponse } from "next"; const t = initTRPC.context().create({ transformer: superjson, diff --git a/src/server/auth.ts b/src/server/auth.ts index 9fd14eb..d98f7f0 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -20,15 +20,15 @@ declare module "next-auth" { interface Session extends DefaultSession { user: { id: string; + stripeSubscriptionStatus: string; // ...other properties // role: UserRole; } & DefaultSession["user"]; } - // interface User { - // // ...other properties - // // role: UserRole; - // } + interface User { + stripeSubscriptionStatus: string; + } } /** @@ -42,6 +42,7 @@ export const authOptions: NextAuthOptions = { ...session, user: { ...session.user, + stripeSubscriptionStatus: user.stripeSubscriptionStatus, id: user.id, }, }), diff --git a/src/server/stripe-webhook-handlers.ts b/src/server/stripe-webhook-handlers.ts new file mode 100644 index 0000000..aae52ba --- /dev/null +++ b/src/server/stripe-webhook-handlers.ts @@ -0,0 +1,123 @@ +import type { PrismaClient } from "@prisma/client"; +import type Stripe from "stripe"; + +// retrieves a Stripe customer id for a given user if it exists or creates a new one +export const getOrCreateStripeCustomerIdForUser = async ({ + stripe, + prisma, + userId, +}: { + stripe: Stripe; + prisma: PrismaClient; + userId: string; +}): Promise => { + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + }); + + if (!user) throw new Error("User not found"); + + if (user.stripeCustomerId) { + return user.stripeCustomerId; + } + + // create a new customer + const customer = await stripe.customers.create({ + email: user.email ?? undefined, + name: user.name ?? undefined, + // use metadata to link this Stripe customer to internal user id + metadata: { + userId, + }, + }); + + // update with new customer id + const updatedUser = await prisma.user.update({ + where: { + id: userId, + }, + data: { + stripeCustomerId: customer.id, + }, + }); + + if (updatedUser.stripeCustomerId) { + return updatedUser.stripeCustomerId; + } + + return null; +}; + +export const handleInvoicePaid = async ({ + event, + stripe, + prisma, +}: { + event: Stripe.Event; + stripe: Stripe; + prisma: PrismaClient; +}) => { + const invoice = event.data.object as Stripe.Invoice; + const subscriptionId = invoice.subscription; + const subscription = await stripe.subscriptions.retrieve( + subscriptionId as string + ); + const userId = subscription.metadata.userId; + + // update user with subscription data + await prisma.user.update({ + where: { + id: userId, + }, + data: { + stripeSubscriptionId: subscription.id, + stripeSubscriptionStatus: subscription.status, + }, + }); +}; + +export const handleSubscriptionCreatedOrUpdated = async ({ + event, + prisma, +}: { + event: Stripe.Event; + prisma: PrismaClient; +}) => { + const subscription = event.data.object as Stripe.Subscription; + const userId = subscription.metadata.userId; + + // update user with subscription data + await prisma.user.update({ + where: { + id: userId, + }, + data: { + stripeSubscriptionId: subscription.id, + stripeSubscriptionStatus: subscription.status, + }, + }); +}; + +export const handleSubscriptionCanceled = async ({ + event, + prisma, +}: { + event: Stripe.Event; + prisma: PrismaClient; +}) => { + const subscription = event.data.object as Stripe.Subscription; + const userId = subscription.metadata.userId; + + // remove subscription data from user + await prisma.user.update({ + where: { + id: userId, + }, + data: { + stripeSubscriptionId: null, + stripeSubscriptionStatus: null, + }, + }); +}; diff --git a/src/server/stripe.ts b/src/server/stripe.ts new file mode 100644 index 0000000..ad68bf7 --- /dev/null +++ b/src/server/stripe.ts @@ -0,0 +1,5 @@ +import Stripe from "stripe"; +import { env } from "~/env.mjs"; +export const stripe = new Stripe(env.STRIPE_SECRET_KEY, { + apiVersion: "2022-11-15", +});