diff --git a/template/apps/api/.env.example b/template/apps/api/.env.example index 56d0f267a..9312b0357 100644 --- a/template/apps/api/.env.example +++ b/template/apps/api/.env.example @@ -48,3 +48,8 @@ WEB_URL=http://localhost:3002 # Google AI (Gemini) # GOOGLE_GENERATIVE_AI_API_KEY=... + +# Stripe +# Get your API keys from https://dashboard.stripe.com/test/apikeys +# STRIPE_SECRET_KEY=sk_test_... +# STRIPE_WEBHOOK_SECRET=whsec_... diff --git a/template/apps/api/package.json b/template/apps/api/package.json index 4e5cbe6d4..d13f9d25d 100644 --- a/template/apps/api/package.json +++ b/template/apps/api/package.json @@ -38,6 +38,7 @@ "ioredis": "5.6.1", "koa": "3.0.3", "koa-body": "6.0.1", + "koa-bodyparser": "4.4.1", "koa-compose": "4.1.0", "koa-helmet": "8.0.1", "koa-logger": "4.0.0", @@ -49,13 +50,16 @@ "mixpanel": "0.18.1", "node-schedule": "2.1.1", "resend": "4.5.2", + "shared": "workspace:*", "socket.io": "4.8.1", + "stripe": "20.3.1", "tldts": "7.0.8", "winston": "3.17.0", "zod": "catalog:" }, "devDependencies": { "@types/koa": "2.15.0", + "@types/koa-bodyparser": "4.3.13", "@types/koa-compose": "3.2.8", "@types/koa-logger": "3.1.5", "@types/koa-mount": "4.0.5", diff --git a/template/apps/api/src/app.ts b/template/apps/api/src/app.ts index a1d9af37a..e4d2fbceb 100644 --- a/template/apps/api/src/app.ts +++ b/template/apps/api/src/app.ts @@ -1,5 +1,6 @@ import cors from '@koa/cors'; import { koaBody } from 'koa-body'; +import bodyParser from 'koa-bodyparser'; import helmet from 'koa-helmet'; import koaLogger from 'koa-logger'; import qs from 'koa-qs'; @@ -25,16 +26,23 @@ const initKoa = async () => { app.use(helmet()); qs(app); app.use( - koaBody({ - multipart: true, - onError: (error, ctx) => { - const errText: string = error.stack || error.toString(); - + bodyParser({ + enableTypes: ['json', 'form', 'text'], + onerror: (err, ctx) => { + const errText: string = err.stack || err.toString(); logger.warn(`Unable to parse request body. ${errText}`); ctx.throw(422, 'Unable to parse request JSON.'); }, }), ); + app.use( + koaBody({ + multipart: true, + json: false, + urlencoded: false, + text: false, + }), + ); app.use( koaLogger({ transporter: (message, args) => { diff --git a/template/apps/api/src/config/index.ts b/template/apps/api/src/config/index.ts index 35f2c0a28..7e71aa34b 100644 --- a/template/apps/api/src/config/index.ts +++ b/template/apps/api/src/config/index.ts @@ -27,6 +27,8 @@ const schema = z.object({ GOOGLE_CLIENT_ID: z.string().optional(), GOOGLE_CLIENT_SECRET: z.string().optional(), GOOGLE_GENERATIVE_AI_API_KEY: z.string().optional(), + STRIPE_SECRET_KEY: z.string().optional(), + STRIPE_WEBHOOK_SECRET: z.string().optional(), }); type Config = z.infer; diff --git a/template/apps/api/src/resources/account/endpoints/verifyEmail.ts b/template/apps/api/src/resources/account/endpoints/verifyEmail.ts index 696b2232e..d0c20acad 100644 --- a/template/apps/api/src/resources/account/endpoints/verifyEmail.ts +++ b/template/apps/api/src/resources/account/endpoints/verifyEmail.ts @@ -6,7 +6,7 @@ import { userService } from 'resources/users'; import isPublic from 'middlewares/isPublic'; import rateLimitMiddleware from 'middlewares/rateLimit'; -import { authService, emailService } from 'services'; +import { authService, emailService, stripeService } from 'services'; import createEndpoint from 'routes/createEndpoint'; import config from 'config'; @@ -45,6 +45,10 @@ export default createEndpoint({ await userService.updateOne({ _id: user._id }, () => ({ isEmailVerified: true })); + if (!user.stripeId) { + await stripeService.createCustomer(user); + } + await authService.setAccessToken({ ctx, userId: user._id }); await emailService.sendTemplate({ diff --git a/template/apps/api/src/resources/subscriptions/endpoints/createPortalSession.ts b/template/apps/api/src/resources/subscriptions/endpoints/createPortalSession.ts new file mode 100644 index 000000000..dc3c5c456 --- /dev/null +++ b/template/apps/api/src/resources/subscriptions/endpoints/createPortalSession.ts @@ -0,0 +1,26 @@ +import { stripeService } from 'services'; +import createEndpoint from 'routes/createEndpoint'; + +import config from 'config'; + +export default createEndpoint({ + method: 'post', + path: '/create-portal-session', + + async handler(ctx) { + const { user } = ctx.state; + + const stripeId = user.stripeId || (await stripeService.createCustomer(user)); + + if (!stripeId) { + return ctx.throwError('Failed to create Stripe customer'); + } + + const session = await stripeService.billingPortal.sessions.create({ + customer: stripeId, + return_url: `${config.WEB_URL}/pricing?portal=true`, + }); + + return { portalUrl: session.url }; + }, +}); diff --git a/template/apps/api/src/resources/subscriptions/endpoints/getCurrent.ts b/template/apps/api/src/resources/subscriptions/endpoints/getCurrent.ts new file mode 100644 index 000000000..7ff78ec53 --- /dev/null +++ b/template/apps/api/src/resources/subscriptions/endpoints/getCurrent.ts @@ -0,0 +1,65 @@ +import Stripe from 'stripe'; + +import { userService } from 'resources/users'; + +import { stripeService } from 'services'; +import createEndpoint from 'routes/createEndpoint'; + +export default createEndpoint({ + method: 'get', + path: '/current', + + async handler(ctx) { + const { user } = ctx.state; + + if (!user.subscription) { + return null; + } + + const stripeSubscription = (await stripeService.subscriptions.retrieve( + user.subscription.subscriptionId, + )) as Stripe.Subscription; + + if (stripeSubscription.status === 'canceled') { + await userService.atomic.updateOne({ _id: user._id }, { $unset: { subscription: '' } }); + return null; + } + + const isCanceled = stripeSubscription.cancel_at_period_end || stripeSubscription.cancel_at !== null; + + const stripePeriodEnd = + stripeSubscription.cancel_at ?? + (stripeSubscription as unknown as { current_period_end: number }).current_period_end; + + const needsUpdate = + user.subscription.cancelAtPeriodEnd !== isCanceled || + user.subscription.status !== stripeSubscription.status || + user.subscription.priceId !== (stripeSubscription.items.data[0]?.price.id ?? user.subscription.priceId) || + user.subscription.currentPeriodEndDate !== stripePeriodEnd; + + if (needsUpdate) { + const updatedSubscription = { + ...user.subscription, + priceId: stripeSubscription.items.data[0]?.price.id ?? user.subscription.priceId, + cancelAtPeriodEnd: isCanceled, + status: stripeSubscription.status, + currentPeriodStartDate: (stripeSubscription as unknown as { current_period_start: number }) + .current_period_start, + currentPeriodEndDate: stripePeriodEnd, + }; + + await userService.atomic.updateOne({ _id: user._id }, { $set: { subscription: updatedSubscription } }); + user.subscription = updatedSubscription; + } + + const product = await stripeService.products.retrieve(user.subscription.productId); + + return { + ...user.subscription, + product: { + name: product.name, + images: product.images, + }, + }; + }, +}); diff --git a/template/apps/api/src/resources/subscriptions/endpoints/previewUpgrade.ts b/template/apps/api/src/resources/subscriptions/endpoints/previewUpgrade.ts new file mode 100644 index 000000000..e1e5d56af --- /dev/null +++ b/template/apps/api/src/resources/subscriptions/endpoints/previewUpgrade.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; + +import { stripeService } from 'services'; +import createEndpoint from 'routes/createEndpoint'; + +const schema = z.object({ + priceId: z.string().min(1, 'Price ID is required'), +}); + +export default createEndpoint({ + method: 'get', + path: '/preview-upgrade', + schema, + + async handler(ctx) { + const { user } = ctx.state; + const { priceId } = ctx.validatedData; + + if (!user.stripeId) { + return ctx.throwError('Stripe account not found.'); + } + + if (!user.subscription?.subscriptionId) { + return ctx.throwError('No active subscription found.'); + } + + const subscriptionId = user.subscription.subscriptionId; + const subscriptionDetails = await stripeService.subscriptions.retrieve(subscriptionId); + + const invoice = await stripeService.invoices.createPreview({ + customer: user.stripeId, + subscription: subscriptionId, + subscription_details: { + proration_behavior: 'always_invoice', + items: [ + { + id: subscriptionDetails.items.data[0].id, + price: priceId, + }, + ], + }, + }); + + return { + subtotal: invoice.subtotal, + total: invoice.total, + amountDue: invoice.amount_due, + currency: invoice.currency, + }; + }, +}); diff --git a/template/apps/api/src/resources/subscriptions/endpoints/subscribe.ts b/template/apps/api/src/resources/subscriptions/endpoints/subscribe.ts new file mode 100644 index 000000000..9f3f5464d --- /dev/null +++ b/template/apps/api/src/resources/subscriptions/endpoints/subscribe.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +import { stripeService } from 'services'; +import createEndpoint from 'routes/createEndpoint'; + +import config from 'config'; + +const schema = z.object({ + priceId: z.string().min(1, 'Price ID is required'), +}); + +export default createEndpoint({ + method: 'post', + path: '/subscribe', + schema, + + async handler(ctx) { + const { user } = ctx.state; + const { priceId } = ctx.validatedData; + + const stripeId = user.stripeId || (await stripeService.createCustomer(user)); + + if (!stripeId) { + return ctx.throwError('Failed to create Stripe customer'); + } + + if (user.subscription) { + return ctx.throwError('You already have an active subscription. Please upgrade instead.'); + } + + const session = await stripeService.checkout.sessions.create({ + mode: 'subscription', + customer: stripeId, + line_items: [ + { + quantity: 1, + price: priceId, + }, + ], + success_url: `${config.WEB_URL}/pricing?success=true`, + cancel_url: `${config.WEB_URL}/pricing?canceled=true`, + }); + + if (!session.url) { + return ctx.throwError('Unable to create checkout session'); + } + + return { checkoutUrl: session.url }; + }, +}); diff --git a/template/apps/api/src/resources/subscriptions/endpoints/upgrade.ts b/template/apps/api/src/resources/subscriptions/endpoints/upgrade.ts new file mode 100644 index 000000000..66310cc11 --- /dev/null +++ b/template/apps/api/src/resources/subscriptions/endpoints/upgrade.ts @@ -0,0 +1,66 @@ +import Stripe from 'stripe'; +import { z } from 'zod'; + +import { userService } from 'resources/users'; + +import { stripeService } from 'services'; +import createEndpoint from 'routes/createEndpoint'; + +const schema = z.object({ + priceId: z.string().min(1, 'Price ID is required'), +}); + +export default createEndpoint({ + method: 'post', + path: '/upgrade', + schema, + + async handler(ctx) { + const { user } = ctx.state; + const { priceId } = ctx.validatedData; + + if (!user.stripeId) { + return ctx.throwError('Stripe account not found. Please contact support.'); + } + + if (!user.subscription?.subscriptionId) { + return ctx.throwError('No active subscription found.'); + } + + const subscriptionId = user.subscription.subscriptionId; + + if (priceId === 'free') { + await stripeService.subscriptions.cancel(subscriptionId); + await userService.atomic.updateOne({ _id: user._id }, { $unset: { subscription: '' } }); + return { success: true, message: 'Subscription canceled' }; + } + + const subscriptionDetails = (await stripeService.subscriptions.retrieve(subscriptionId)) as Stripe.Subscription; + + const updatedStripeSubscription = (await stripeService.subscriptions.update(subscriptionId, { + proration_behavior: 'always_invoice', + cancel_at_period_end: false, + items: [ + { + id: subscriptionDetails.items.data[0].id, + price: priceId, + }, + ], + })) as Stripe.Subscription; + + const updatedSubscription = { + ...user.subscription, + priceId: updatedStripeSubscription.items.data[0]?.price.id ?? priceId, + productId: updatedStripeSubscription.items.data[0]?.price.product as string, + status: updatedStripeSubscription.status, + cancelAtPeriodEnd: updatedStripeSubscription.cancel_at_period_end, + currentPeriodStartDate: (updatedStripeSubscription as unknown as { current_period_start: number }) + .current_period_start, + currentPeriodEndDate: (updatedStripeSubscription as unknown as { current_period_end: number }).current_period_end, + }; + + await userService.atomic.updateOne({ _id: user._id }, { $set: { subscription: updatedSubscription } }); + + return { success: true }; + }, +}); diff --git a/template/apps/api/src/resources/users/user.schema.ts b/template/apps/api/src/resources/users/user.schema.ts index 090ed93f9..95c155952 100644 --- a/template/apps/api/src/resources/users/user.schema.ts +++ b/template/apps/api/src/resources/users/user.schema.ts @@ -1,3 +1,4 @@ +import { subscriptionSchema } from 'shared'; import { z } from 'zod'; import { dbSchema, emailSchema } from '../base.schema'; @@ -25,6 +26,9 @@ export const userSchema = dbSchema.extend({ .optional(), lastRequest: z.date().optional(), + + stripeId: z.string().optional().nullable(), + subscription: subscriptionSchema.optional().nullable(), }); export type User = z.infer; diff --git a/template/apps/api/src/resources/webhook/endpoints/stripe.ts b/template/apps/api/src/resources/webhook/endpoints/stripe.ts new file mode 100644 index 000000000..02b07a6ca --- /dev/null +++ b/template/apps/api/src/resources/webhook/endpoints/stripe.ts @@ -0,0 +1,104 @@ +import Stripe from 'stripe'; + +import { userService } from 'resources/users'; + +import isPublic from 'middlewares/isPublic'; +import { stripeService } from 'services'; +import createEndpoint from 'routes/createEndpoint'; + +import config from 'config'; + +import logger from 'logger'; + +interface SubscriptionEventData { + id: string; + customer: string; + status: Stripe.Subscription.Status; + cancel_at_period_end: boolean; + cancel_at: number | null; + current_period_start: number; + current_period_end: number; + plan: { + id: string; + product: string; + interval: string; + }; +} + +const updateUserSubscription = async (data: SubscriptionEventData) => { + const isCanceled = data.cancel_at_period_end || data.cancel_at !== null; + + const subscription = { + subscriptionId: data.id, + priceId: data.plan.id, + productId: data.plan?.product, + status: data.status, + interval: data.plan?.interval, + currentPeriodStartDate: data.current_period_start, + currentPeriodEndDate: data.current_period_end, + cancelAtPeriodEnd: isCanceled, + }; + + return userService.atomic.updateOne({ stripeId: data.customer }, { $set: { subscription } }); +}; + +const deleteUserSubscription = async (customerId: string) => { + return userService.atomic.updateOne({ stripeId: customerId }, { $unset: { subscription: '' } }); +}; + +export default createEndpoint({ + method: 'post' as const, + path: '/stripe', + middlewares: [isPublic], + handler: async (ctx) => { + const signature = ctx.request.header['stripe-signature']; + + if (!signature) { + return ctx.throwError('Stripe signature header is missing'); + } + + if (!config.STRIPE_WEBHOOK_SECRET) { + return ctx.throwError('Stripe webhook secret is not configured'); + } + + let event: Stripe.Event; + + try { + event = stripeService.webhooks.constructEvent( + (ctx.request as unknown as { rawBody: string }).rawBody, + signature, + config.STRIPE_WEBHOOK_SECRET, + ); + } catch (err) { + return ctx.throwError(`Webhook signature verification failed: ${err}`); + } + + switch (event.type) { + case 'customer.subscription.created': + await updateUserSubscription(event.data.object as unknown as SubscriptionEventData); + logger.info( + `Subscription created for customer ${(event.data.object as unknown as SubscriptionEventData).customer}`, + ); + break; + + case 'customer.subscription.updated': + await updateUserSubscription(event.data.object as unknown as SubscriptionEventData); + logger.info( + `Subscription updated for customer ${(event.data.object as unknown as SubscriptionEventData).customer}`, + ); + break; + + case 'customer.subscription.deleted': + await deleteUserSubscription((event.data.object as unknown as SubscriptionEventData).customer); + logger.info( + `Subscription deleted for customer ${(event.data.object as unknown as SubscriptionEventData).customer}`, + ); + break; + + default: + logger.info(`Unhandled event type: ${event.type}`); + } + + return { received: true }; + }, +}); diff --git a/template/apps/api/src/services/index.ts b/template/apps/api/src/services/index.ts index 7ffe0e22a..e9d7c3e2a 100644 --- a/template/apps/api/src/services/index.ts +++ b/template/apps/api/src/services/index.ts @@ -5,5 +5,15 @@ import cloudStorageService from './cloud-storage/cloud-storage.service'; import emailService from './email/email.service'; import * as googleService from './google/google.service'; import socketService from './socket/socket.service'; +import stripeService from './stripe/stripe.service'; -export { aiService, analyticsService, authService, cloudStorageService, emailService, googleService, socketService }; +export { + aiService, + analyticsService, + authService, + cloudStorageService, + emailService, + googleService, + socketService, + stripeService, +}; diff --git a/template/apps/api/src/services/stripe/stripe.service.ts b/template/apps/api/src/services/stripe/stripe.service.ts new file mode 100644 index 000000000..4b28a9568 --- /dev/null +++ b/template/apps/api/src/services/stripe/stripe.service.ts @@ -0,0 +1,79 @@ +import { ClientSession } from '@paralect/node-mongo'; +import Stripe from 'stripe'; + +import { userService } from 'resources/users'; +import type { User } from 'resources/users/user.schema'; + +import config from 'config'; + +import logger from 'logger'; + +const stripe = config.STRIPE_SECRET_KEY ? new Stripe(config.STRIPE_SECRET_KEY) : null; + +const createCustomer = async (user: User, session?: ClientSession): Promise => { + if (!stripe) { + logger.warn('[Stripe] Service not initialized - STRIPE_SECRET_KEY not provided'); + return null; + } + + try { + const customer = await stripe.customers.create({ + email: user.email, + name: `${user.firstName} ${user.lastName}`, + }); + + await userService.atomic.updateOne( + { _id: user._id }, + { + $set: { + stripeId: customer.id, + }, + }, + {}, + { session }, + ); + + logger.info(`[Stripe] Created customer ${customer.id} for user ${user._id}`); + + return customer.id; + } catch (error) { + logger.error(`[Stripe] Error creating customer for user ${user._id}`, error); + throw error; + } +}; + +const getStripe = () => { + if (!stripe) { + throw new Error('[Stripe] Service not initialized - STRIPE_SECRET_KEY not provided'); + } + return stripe; +}; + +export default { + createCustomer, + getStripe, + get subscriptions() { + return getStripe().subscriptions; + }, + get customers() { + return getStripe().customers; + }, + get checkout() { + return getStripe().checkout; + }, + get billingPortal() { + return getStripe().billingPortal; + }, + get products() { + return getStripe().products; + }, + get invoices() { + return getStripe().invoices; + }, + get setupIntents() { + return getStripe().setupIntents; + }, + get webhooks() { + return getStripe().webhooks; + }, +}; diff --git a/template/apps/web/.env.development b/template/apps/web/.env.development index e6f6638aa..1823ccc80 100644 --- a/template/apps/web/.env.development +++ b/template/apps/web/.env.development @@ -7,3 +7,8 @@ NEXT_PUBLIC_WEB_URL=http://localhost:3002 # Mixpanel # NEXT_PUBLIC_MIXPANEL_API_KEY=... + +# Stripe +# NEXT_PUBLIC_STRIPE_PUBLIC_KEY= +# NEXT_PUBLIC_PRICE_CREATOR= +# NEXT_PUBLIC_PRICE_PRO= \ No newline at end of file diff --git a/template/apps/web/src/config/index.ts b/template/apps/web/src/config/index.ts index 901bb8b7a..badb41aa4 100644 --- a/template/apps/web/src/config/index.ts +++ b/template/apps/web/src/config/index.ts @@ -15,6 +15,9 @@ const schema = z.object({ WS_URL: z.string(), WEB_URL: z.string(), MIXPANEL_API_KEY: z.string().optional(), + STRIPE_PUBLIC_KEY: z.string().optional(), + PRICE_CREATOR: z.string().optional(), + PRICE_PRO: z.string().optional(), }); type Config = z.infer; @@ -29,6 +32,9 @@ const processEnv = { WS_URL: process.env.NEXT_PUBLIC_WS_URL, WEB_URL: process.env.NEXT_PUBLIC_WEB_URL, MIXPANEL_API_KEY: process.env.NEXT_PUBLIC_MIXPANEL_API_KEY, + STRIPE_PUBLIC_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY, + PRICE_CREATOR: process.env.NEXT_PUBLIC_PRICE_CREATOR, + PRICE_PRO: process.env.NEXT_PUBLIC_PRICE_PRO, } as Record; const config = validateConfig(schema, processEnv); diff --git a/template/apps/web/src/pages/_app/PageConfig/MainLayout/components/Navigation.tsx b/template/apps/web/src/pages/_app/PageConfig/MainLayout/components/Navigation.tsx index bf7efbc8f..260b29742 100644 --- a/template/apps/web/src/pages/_app/PageConfig/MainLayout/components/Navigation.tsx +++ b/template/apps/web/src/pages/_app/PageConfig/MainLayout/components/Navigation.tsx @@ -3,7 +3,7 @@ import Link from 'next/link'; import { useRouter } from 'next/router'; import { useQueryClient } from '@tanstack/react-query'; import { useApiQuery } from 'hooks'; -import { ChevronDown, Home, MessageSquare, Plus, Trash2, Users } from 'lucide-react'; +import { ChevronDown, CreditCard, Home, MessageSquare, Plus, Trash2, Users } from 'lucide-react'; import { apiClient } from 'services/api-client.service'; @@ -78,6 +78,20 @@ const Navigation = ({ isCollapsed }: NavigationProps) => { + +
+ + {!isCollapsed && Pricing} +
+ +
{ + const router = useRouter(); + const searchParams = useSearchParams(); + const success = searchParams.get('success'); + const canceled = searchParams.get('canceled'); + const portal = searchParams.get('portal'); + + const { + data: subscription, + isLoading: isSubscriptionLoading, + refetch: refetchSubscription, + } = useApiQuery(apiClient.subscriptions.getCurrent); + + const { mutate: subscribe, isPending: isSubscribePending } = useApiMutation(apiClient.subscriptions.subscribe, { + onSuccess: (data) => { + window.location.href = data.checkoutUrl; + }, + onError: () => { + toast.error('Failed to start checkout'); + }, + }); + + const { mutate: upgrade, isPending: isUpgradePending } = useApiMutation(apiClient.subscriptions.upgrade, { + onSuccess: () => { + toast.success('Plan changed successfully!'); + refetchSubscription(); + }, + onError: () => { + toast.error('Failed to change plan'); + }, + }); + + const { mutate: createPortalSession, isPending: isPortalPending } = useApiMutation( + apiClient.subscriptions.createPortalSession, + { + onSuccess: (data) => { + window.location.href = data.portalUrl; + }, + onError: () => { + toast.error('Failed to open billing portal'); + }, + }, + ); + + useEffect(() => { + if (success) { + toast.success('Subscription successful! Welcome aboard.'); + refetchSubscription(); + } else if (canceled) { + toast.info('Checkout was canceled.'); + } else if (portal) { + refetchSubscription(); + } else { + return; + } + + router.replace(RoutePath.Pricing); + }, [success, canceled, portal, router, refetchSubscription]); + + const currentPriceId = subscription?.priceId; + const hasSubscription = !!subscription; + const isCanceled = subscription?.cancelAtPeriodEnd; + const periodEndDate = subscription?.currentPeriodEndDate + ? new Date(subscription.currentPeriodEndDate * 1000).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }) + : null; + + const handlePlanAction = (priceId: string | null) => { + if (!priceId) return; + + if (hasSubscription) { + upgrade({ priceId }); + } + + if (!hasSubscription) { + subscribe({ priceId }); + } + }; + + const getPlanButton = (plan: (typeof plans)[0]) => { + const currentPlanIndex = plans.findIndex((p) => p.priceId === currentPriceId); + const targetPlanIndex = plans.findIndex((p) => p.priceId === plan.priceId); + + const isFreePlan = !plan.priceId; + const isCurrentPlan = plan.priceId === currentPriceId; + const isDowngrade = hasSubscription && targetPlanIndex < currentPlanIndex; + const isPending = isSubscribePending || isUpgradePending; + + const openPortal = () => createPortalSession({}); + + interface ButtonConfig { + label: string; + disabled?: boolean; + onClick?: () => void; + loading?: boolean; + outline?: boolean; + } + + const config: ButtonConfig = (() => { + if (isFreePlan && isCanceled) return { label: `Starting ${periodEndDate}`, disabled: true }; + if (isFreePlan && !hasSubscription) return { label: 'Current Plan', disabled: true }; + if (isCurrentPlan) return { label: isCanceled ? `Until ${periodEndDate}` : 'Current Plan', disabled: true }; + + if (isCanceled) return { label: 'Resubscribe', onClick: openPortal, loading: isPortalPending }; + if (isDowngrade && isFreePlan) + return { label: 'Downgrade', onClick: openPortal, loading: isPortalPending, outline: true }; + + const label = isDowngrade ? 'Downgrade' : hasSubscription ? 'Upgrade' : 'Subscribe'; + + return { label, onClick: () => handlePlanAction(plan.priceId), loading: isPending, outline: isDowngrade }; + })(); + + const showOutline = config.disabled || config.outline || !plan.popular; + + return ( + + ); + }; + + if (isSubscriptionLoading) { + return ( +
+ +
+ ); + } + + return ( + <> + + Pricing + + +
+
+

Choose Your Plan

+

+ Select the plan that best fits your needs. Upgrade or downgrade anytime. +

+
+ +
+ {plans.map((plan) => ( + + {plan.popular && ( +
+ + Most Popular + +
+ )} + + + {plan.name} + {plan.description} +
+ {plan.price} + {plan.priceId && /month} +
+
+ + +
    + {plan.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+ + {getPlanButton(plan)} +
+ ))} +
+
+ + ); +}; + +export default Pricing; diff --git a/template/apps/web/src/routes.ts b/template/apps/web/src/routes.ts index 2ec896db0..071e5008d 100644 --- a/template/apps/web/src/routes.ts +++ b/template/apps/web/src/routes.ts @@ -15,6 +15,7 @@ export enum RoutePath { ChatIndex = '/chat', Chat = '/chat/[chatId]', Profile = '/profile', + Pricing = '/pricing', // Auth paths SignIn = '/sign-in', @@ -54,6 +55,10 @@ export const routesConfiguration: RoutesConfiguration = { scope: ScopeType.PRIVATE, layout: LayoutType.MAIN, }, + [RoutePath.Pricing]: { + scope: ScopeType.PRIVATE, + layout: LayoutType.MAIN, + }, // Auth routes [RoutePath.SignIn]: { diff --git a/template/packages/shared/src/generated/index.ts b/template/packages/shared/src/generated/index.ts index 130e0ef1d..6f67c4eb0 100644 --- a/template/packages/shared/src/generated/index.ts +++ b/template/packages/shared/src/generated/index.ts @@ -8,6 +8,7 @@ import { messageSchema, paginationSchema, passwordSchema, + subscriptionSchema, userPublicSchema, userSchema, } from "../schemas"; @@ -59,9 +60,16 @@ export const schemas = { sendMessage: z.object({ content: z.string().min(1), }), - sendMessageStreamResponse: z.object({ - type: z.literal("done"), - messageId: z.string(), + }, + subscriptions: { + previewUpgrade: z.object({ + priceId: z.string().min(1, "Price ID is required"), + }), + subscribe: z.object({ + priceId: z.string().min(1, "Price ID is required"), + }), + upgrade: z.object({ + priceId: z.string().min(1, "Price ID is required"), }), }, users: { @@ -86,6 +94,7 @@ export const schemas = { }), update: userSchema.pick({ firstName: true, lastName: true, email: true }), }, + webhook: {}, } as const; export interface ChatsDeletePathParams { @@ -127,6 +136,15 @@ export type ChatsDeleteParams = z.infer; export type ChatsGetMessagesParams = z.infer; export type ChatsListParams = z.infer; export type ChatsSendMessageParams = z.infer; +export type SubscriptionsPreviewUpgradeParams = z.infer< + typeof schemas.subscriptions.previewUpgrade +>; +export type SubscriptionsSubscribeParams = z.infer< + typeof schemas.subscriptions.subscribe +>; +export type SubscriptionsUpgradeParams = z.infer< + typeof schemas.subscriptions.upgrade +>; export type UsersListParams = z.infer; export type UsersUpdateParams = z.infer; @@ -137,15 +155,34 @@ export interface AccountSignUpResponse { } export type AccountUpdateResponse = z.infer; export type ChatsCreateResponse = z.infer; -export type ChatsSendMessageStreamResponse = z.infer< - typeof schemas.chats.sendMessageStreamResponse ->; export type ChatsGetMessagesResponse = z.infer[]; export type ChatsListResponse = z.infer[]; +export interface ChatsSendMessageStreamResponse { + type: "done"; + messageId: string; +} export type UsersListResponse = z.infer< ReturnType> >; export type UsersUpdateResponse = z.infer; +export type SubscriptionsCurrentResponse = z.infer< + typeof subscriptionSchema +> | null; +export interface SubscriptionsSubscribeResponse { + checkoutUrl: string; +} +export interface SubscriptionsUpgradeResponse { + subscriptionId: string; +} +export interface SubscriptionsPreviewUpgradeResponse { + subtotal: number; + total: number; + amountDue: number; + prorationDate: number; +} +export interface SubscriptionsPortalResponse { + portalUrl: string; +} function createAccountEndpoints(client: ApiClient) { return { @@ -307,6 +344,61 @@ function createChatsEndpoints(client: ApiClient) { }; } +function createSubscriptionsEndpoints(client: ApiClient) { + return { + createPortalSession: { + method: "post" as const, + path: "/subscriptions/create-portal-session" as const, + schema: undefined, + call: (params?: Record) => + client.post( + "/subscriptions/create-portal-session", + params, + ), + }, + getCurrent: { + method: "get" as const, + path: "/subscriptions/current" as const, + schema: undefined, + call: (params?: Record) => + client.get( + "/subscriptions/current", + params, + ), + }, + previewUpgrade: { + method: "get" as const, + path: "/subscriptions/preview-upgrade" as const, + schema: schemas.subscriptions.previewUpgrade, + call: (params: SubscriptionsPreviewUpgradeParams) => + client.get( + "/subscriptions/preview-upgrade", + params, + ), + }, + subscribe: { + method: "post" as const, + path: "/subscriptions/subscribe" as const, + schema: schemas.subscriptions.subscribe, + call: (params: SubscriptionsSubscribeParams) => + client.post( + "/subscriptions/subscribe", + params, + ), + }, + upgrade: { + method: "post" as const, + path: "/subscriptions/upgrade" as const, + schema: schemas.subscriptions.upgrade, + call: (params: SubscriptionsUpgradeParams) => + client.post( + "/subscriptions/upgrade", + params, + ), + }, + }; +} + function createUsersEndpoints(client: ApiClient) { return { list: { @@ -353,11 +445,25 @@ function createUsersEndpoints(client: ApiClient) { }; } +function createWebhookEndpoints(client: ApiClient) { + return { + stripe: { + method: "post" as const, + path: "/webhook/stripe" as const, + schema: undefined, + call: (params?: Record) => + client.post("/webhook/stripe", params), + }, + }; +} + export function createApiEndpoints(client: ApiClient) { return { account: createAccountEndpoints(client), chats: createChatsEndpoints(client), + subscriptions: createSubscriptionsEndpoints(client), users: createUsersEndpoints(client), + webhook: createWebhookEndpoints(client), }; } diff --git a/template/packages/shared/src/schemas/index.ts b/template/packages/shared/src/schemas/index.ts index 568c6f366..f322266af 100644 --- a/template/packages/shared/src/schemas/index.ts +++ b/template/packages/shared/src/schemas/index.ts @@ -1,5 +1,6 @@ export * from "./base.schema"; export * from "./chats/chat.schema"; export * from "./chats/message.schema"; +export * from "./subscription/subscription.schema"; export * from "./token/token.schema"; export * from "./users/user.schema"; diff --git a/template/packages/shared/src/schemas/subscription/subscription.schema.ts b/template/packages/shared/src/schemas/subscription/subscription.schema.ts new file mode 100644 index 000000000..929cbb31f --- /dev/null +++ b/template/packages/shared/src/schemas/subscription/subscription.schema.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +export const subscriptionSchema = z.object({ + subscriptionId: z.string(), + priceId: z.string(), + productId: z.string(), + status: z.string(), + interval: z.string(), + currentPeriodStartDate: z.number(), + currentPeriodEndDate: z.number(), + cancelAtPeriodEnd: z.boolean(), + product: z + .object({ + name: z.string(), + images: z.array(z.string()), + }) + .optional(), + pendingInvoice: z + .object({ + subtotal: z.number(), + total: z.number(), + amountDue: z.number(), + status: z.string(), + created: z.number(), + }) + .optional(), +}); + +export type Subscription = z.infer; diff --git a/template/packages/shared/src/schemas/users/user.schema.ts b/template/packages/shared/src/schemas/users/user.schema.ts index 7262a4744..29fb14e0b 100644 --- a/template/packages/shared/src/schemas/users/user.schema.ts +++ b/template/packages/shared/src/schemas/users/user.schema.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { dbSchema, emailSchema } from "../base.schema"; +import { subscriptionSchema } from "../subscription/subscription.schema"; export const userSchema = dbSchema.extend({ firstName: z @@ -31,6 +32,9 @@ export const userSchema = dbSchema.extend({ .optional(), lastRequest: z.date().optional(), + + stripeId: z.string().optional().nullable(), + subscription: subscriptionSchema.optional().nullable(), }); export type User = z.infer; diff --git a/template/pnpm-lock.yaml b/template/pnpm-lock.yaml index 6d2dda49b..41b1de7b5 100644 --- a/template/pnpm-lock.yaml +++ b/template/pnpm-lock.yaml @@ -123,6 +123,9 @@ importers: koa-body: specifier: 6.0.1 version: 6.0.1 + koa-bodyparser: + specifier: 4.4.1 + version: 4.4.1 koa-compose: specifier: 4.1.0 version: 4.1.0 @@ -156,9 +159,15 @@ importers: resend: specifier: 4.5.2 version: 4.5.2(react-dom@19.0.3(react@19.0.3))(react@19.0.3) + shared: + specifier: workspace:* + version: link:../../packages/shared socket.io: specifier: 4.8.1 version: 4.8.1 + stripe: + specifier: 20.3.1 + version: 20.3.1(@types/node@22.10.10) tldts: specifier: 7.0.8 version: 7.0.8 @@ -172,6 +181,9 @@ importers: '@types/koa': specifier: 2.15.0 version: 2.15.0 + '@types/koa-bodyparser': + specifier: 4.3.13 + version: 4.3.13 '@types/koa-compose': specifier: 3.2.8 version: 3.2.8 @@ -3608,6 +3620,9 @@ packages: '@types/keygrip@1.0.6': resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==} + '@types/koa-bodyparser@4.3.13': + resolution: {integrity: sha512-yW9afsDnV1ymnhMBfAlzsKZ45sYnvIlTRS+eF5MUZOQ8ePIIj3ajwi+9b4a0vdyYvh6uJKYibU8R1zrUIpCmCA==} + '@types/koa-compose@3.2.8': resolution: {integrity: sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA==} @@ -4177,6 +4192,9 @@ packages: resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==} engines: {node: '>= 0.8'} + copy-to@2.0.1: + resolution: {integrity: sha512-3DdaFaU/Zf1AnpLiFDeNCD4TOWe3Zl2RZaTzUvWiIk5ERzcCodOE20Vqq4fzCbNoHURFHT4/us/Lfq+S2zyY4w==} + core-js-compat@3.46.0: resolution: {integrity: sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law==} @@ -5367,6 +5385,10 @@ packages: koa-body@6.0.1: resolution: {integrity: sha512-M8ZvMD8r+kPHy28aWP9VxL7kY8oPWA+C7ZgCljrCMeaU7uX6wsIQgDHskyrAr9sw+jqnIXyv4Mlxri5R4InIJg==} + koa-bodyparser@4.4.1: + resolution: {integrity: sha512-kBH3IYPMb+iAXnrxIhXnW+gXV8OTzCu8VPDqvcDHW9SQrbkHmqPQtiZwrltNmSq6/lpipHnT7k7PsjlVD7kK0w==} + engines: {node: '>=8.0.0'} + koa-compose@4.1.0: resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==} @@ -6571,6 +6593,15 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + stripe@20.3.1: + resolution: {integrity: sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ==} + engines: {node: '>=16'} + peerDependencies: + '@types/node': '>=16' + peerDependenciesMeta: + '@types/node': + optional: true + strnum@1.1.2: resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} @@ -10959,6 +10990,10 @@ snapshots: '@types/keygrip@1.0.6': {} + '@types/koa-bodyparser@4.3.13': + dependencies: + '@types/koa': 2.15.0 + '@types/koa-compose@3.2.8': dependencies: '@types/koa': 2.15.0 @@ -11594,6 +11629,8 @@ snapshots: depd: 2.0.0 keygrip: 1.1.0 + copy-to@2.0.1: {} + core-js-compat@3.46.0: dependencies: browserslist: 4.27.0 @@ -12994,6 +13031,12 @@ snapshots: formidable: 2.1.5 zod: 3.24.3 + koa-bodyparser@4.4.1: + dependencies: + co-body: 6.2.0 + copy-to: 2.0.1 + type-is: 1.6.18 + koa-compose@4.1.0: {} koa-helmet@8.0.1(helmet@8.1.0): @@ -14550,6 +14593,10 @@ snapshots: strip-json-comments@3.1.1: {} + stripe@20.3.1(@types/node@22.10.10): + optionalDependencies: + '@types/node': 22.10.10 + strnum@1.1.2: {} strnum@2.1.1: