diff --git a/.env.example b/.env.example index 5850aed..e9d82d4 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,6 @@ SUPABASE_URL= DEEPGRAM_API_KEY= OPENAI_API_KEY= SLACK_POSTMAN_WEBHOOK_URL= + +STRIPE_SECRET_KEY= +STRIPE_PUBLISHABLE_KEY= diff --git a/env.mjs b/env.mjs index 2a1c736..035b46c 100644 --- a/env.mjs +++ b/env.mjs @@ -19,6 +19,8 @@ export const env = createEnv({ PODCAST_INDEX_API_KEY: process.env.PODCAST_INDEX_API_KEY, PODCAST_INDEX_SECRET: process.env.PODCAST_INDEX_SECRET, SLACK_POSTMAN_WEBHOOK_URL: process.env.SLACK_POSTMAN_WEBHOOK_URL, + STRIPE_PUBLISHABLE_KEY: process.env.STRIPE_PUBLISHABLE_KEY, + STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY, SUPABASE_URL: process.env.SUPABASE_URL, }, @@ -29,6 +31,8 @@ export const env = createEnv({ PODCAST_INDEX_API_KEY: z.string().min(1), PODCAST_INDEX_SECRET: z.string().min(1), SLACK_POSTMAN_WEBHOOK_URL: z.string().min(1), + STRIPE_PUBLISHABLE_KEY: z.string().min(1), + STRIPE_SECRET_KEY: z.string().min(1), SUPABASE_SERVICE_ROLE_KEY: z.string().min(1), SUPABASE_URL: z.string().min(1), }, diff --git a/lib/errors.ts b/lib/errors.ts index 2d73809..e9403df 100644 --- a/lib/errors.ts +++ b/lib/errors.ts @@ -80,3 +80,14 @@ export class DatabaseError extends ExtendableError { this.underlyingError = error; } } + +export const createExternalServiceError = (serviceName: string) => + class ExternalServiceError extends ExtendableError { + serviceName = serviceName; + underlyingError: unknown; + + constructor(error: unknown) { + super(`Error from ${serviceName}`); + this.underlyingError = error; + } + }; diff --git a/lib/services/stripe/checkout.ts b/lib/services/stripe/checkout.ts new file mode 100644 index 0000000..59baf78 --- /dev/null +++ b/lib/services/stripe/checkout.ts @@ -0,0 +1,25 @@ +'use server'; + +import { stripe } from './client'; + +export const createCheckoutSession = async ({ + baseUrl, + priceId, +}: { + baseUrl: string; + priceId: string; +}) => { + const session = await stripe.checkout.sessions.create({ + cancel_url: `${baseUrl}?status=cancel`, + line_items: [ + { + price: priceId, + quantity: 1, + }, + ], + mode: 'payment', + success_url: `${baseUrl}?status=success`, + }); + + return session; +}; diff --git a/lib/services/stripe/client.ts b/lib/services/stripe/client.ts new file mode 100644 index 0000000..fae1f13 --- /dev/null +++ b/lib/services/stripe/client.ts @@ -0,0 +1,7 @@ +import { env } from '@/env.mjs'; +import { createExternalServiceError } from '@/lib/errors'; +import { Stripe } from 'stripe'; + +export const stripe = new Stripe(env.STRIPE_SECRET_KEY); + +export const StripeError = createExternalServiceError('Stripe'); diff --git a/lib/services/stripe/prices.ts b/lib/services/stripe/prices.ts new file mode 100644 index 0000000..6e74b1a --- /dev/null +++ b/lib/services/stripe/prices.ts @@ -0,0 +1,22 @@ +'use server'; + +import { StripeError, stripe } from './client'; + +const getProduct = async () => { + const products = await stripe.products.list(); + const product = products.data.find((p) => p.metadata.project === 'beecast'); + + if (!product) { + throw new StripeError('Product not found!'); + } + + return product; +}; + +export const getPrices = async () => { + const product = await getProduct(); + const prices = await stripe.prices.list({ product: product.id }); + return prices.data + .filter((p) => p.active) + .sort((a, b) => a.unit_amount! - b.unit_amount!); +}; diff --git a/package.json b/package.json index 050561e..f0a3d94 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "es6-error": "^4.1.1", "format-duration": "^3.0.2", "html-react-parser": "^5.0.7", + "jotai": "^2.6.0", "next": "14.0.4", "next-themes": "1.0.0-beta.0", "ofetch": "^1.3.3", @@ -36,6 +37,7 @@ "react": "^18", "react-dom": "^18", "react-icons": "^4.12.0", + "stripe": "^14.9.0", "zod": "^3.22.4" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9aff200..211d5e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ dependencies: html-react-parser: specifier: ^5.0.7 version: 5.0.7(react@18.2.0) + jotai: + specifier: ^2.6.0 + version: 2.6.0(@types/react@18.2.43)(react@18.2.0) next: specifier: 14.0.4 version: 14.0.4(@babel/core@7.23.5)(react-dom@18.2.0)(react@18.2.0) @@ -62,6 +65,9 @@ dependencies: react-icons: specifier: ^4.12.0 version: 4.12.0(react@18.2.0) + stripe: + specifier: ^14.9.0 + version: 14.9.0 zod: specifier: ^3.22.4 version: 3.22.4 @@ -6328,7 +6334,6 @@ packages: function-bind: 1.1.2 get-intrinsic: 1.2.2 set-function-length: 1.1.1 - dev: true /callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} @@ -7075,7 +7080,6 @@ packages: get-intrinsic: 1.2.2 gopd: 1.0.1 has-property-descriptors: 1.0.1 - dev: true /define-lazy-prop@2.0.0: resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} @@ -8548,7 +8552,6 @@ packages: /function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - dev: true /function.prototype.name@1.1.6: resolution: {integrity: sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==} @@ -8589,7 +8592,6 @@ packages: has-proto: 1.0.1 has-symbols: 1.0.3 hasown: 2.0.0 - dev: true /get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} @@ -8781,7 +8783,6 @@ packages: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} dependencies: get-intrinsic: 1.2.2 - dev: true /graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -8837,17 +8838,14 @@ packages: resolution: {integrity: sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==} dependencies: get-intrinsic: 1.2.2 - dev: true /has-proto@1.0.1: resolution: {integrity: sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==} engines: {node: '>= 0.4'} - dev: true /has-symbols@1.0.3: resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} engines: {node: '>= 0.4'} - dev: true /has-tostringtag@1.0.0: resolution: {integrity: sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==} @@ -8877,7 +8875,6 @@ packages: engines: {node: '>= 0.4'} dependencies: function-bind: 1.1.2 - dev: true /he@1.2.0: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} @@ -9598,6 +9595,22 @@ packages: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} dev: true + /jotai@2.6.0(@types/react@18.2.43)(react@18.2.0): + resolution: {integrity: sha512-Vt6hsc04Km4j03l+Ax+Sc+FVft5cRJhqgxt6GTz6GM2eM3DyX3CdBdzcG0z2FrlZToL1/0OAkqDghIyARWnSuQ==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + dependencies: + '@types/react': 18.2.43 + react: 18.2.0 + dev: false + /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -10510,7 +10523,6 @@ packages: /object-inspect@1.13.1: resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==} - dev: true /object-is@1.1.5: resolution: {integrity: sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==} @@ -11250,7 +11262,6 @@ packages: engines: {node: '>=0.6'} dependencies: side-channel: 1.0.4 - dev: true /querystring-es3@0.2.1: resolution: {integrity: sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==} @@ -11939,7 +11950,6 @@ packages: get-intrinsic: 1.2.2 gopd: 1.0.1 has-property-descriptors: 1.0.1 - dev: true /set-function-name@2.0.1: resolution: {integrity: sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==} @@ -12006,7 +12016,6 @@ packages: call-bind: 1.0.5 get-intrinsic: 1.2.2 object-inspect: 1.13.1 - dev: true /signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -12377,6 +12386,14 @@ packages: engines: {node: '>=8'} dev: true + /stripe@14.9.0: + resolution: {integrity: sha512-t2XdpNbRH4x3MYEoxNWhwUPl9D80aUd5OHds0zhDiwRYPZ0H7MrUI/dj9wOSYlzycD3xdvjn0q7pWeFWljtMUQ==} + engines: {node: '>=12.*'} + dependencies: + '@types/node': 20.10.4 + qs: 6.11.2 + dev: false + /style-loader@3.3.3(webpack@5.89.0): resolution: {integrity: sha512-53BiGLXAcll9maCYtZi2RCQZKa8NQQai5C4horqKyRmHj9H7QmcUyucrH+4KW/gBQbXM2AsB0axoEcFZPlfPcw==} engines: {node: '>= 12.13.0'}