From c6a8564b7387a052bf3b17605ca5b9ef1a68e834 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 23 Jul 2025 14:20:17 +0000 Subject: [PATCH] feat: Integrate credit system into Next.js SaaS Starter --- app/(dashboard)/credits/page.tsx | 22 ++ app/(dashboard)/dashboard/page.tsx | 16 +- app/(dashboard)/layout.tsx | 8 +- app/api/ai-generation/route.ts | 32 +++ app/api/credits/balance/route.ts | 16 ++ app/api/credits/grants-history/route.ts | 23 ++ app/api/credits/usage-history/route.ts | 23 ++ app/api/credits/use/route.ts | 28 +++ app/api/stripe/webhook/route.ts | 34 +-- components/ai/ai-generation-component.tsx | 34 +++ components/credits/credit-components.tsx | 171 +++++++++++++++ lib/db/schema.ts | 29 +++ lib/services/credit-service.ts | 190 +++++++++++++++++ lib/services/stripe-webhook-handler.ts | 44 ++++ lib/utils.ts | 7 + scripts/stripe-setup.js | 247 ++++++++++++++++++++++ 16 files changed, 890 insertions(+), 34 deletions(-) create mode 100644 app/(dashboard)/credits/page.tsx create mode 100644 app/api/ai-generation/route.ts create mode 100644 app/api/credits/balance/route.ts create mode 100644 app/api/credits/grants-history/route.ts create mode 100644 app/api/credits/usage-history/route.ts create mode 100644 app/api/credits/use/route.ts create mode 100644 components/ai/ai-generation-component.tsx create mode 100644 components/credits/credit-components.tsx create mode 100644 lib/services/credit-service.ts create mode 100644 lib/services/stripe-webhook-handler.ts create mode 100644 scripts/stripe-setup.js diff --git a/app/(dashboard)/credits/page.tsx b/app/(dashboard)/credits/page.tsx new file mode 100644 index 000000000..b3c1a9dcd --- /dev/null +++ b/app/(dashboard)/credits/page.tsx @@ -0,0 +1,22 @@ +import { CreditDashboard } from '@/components/credits/credit-components'; +import { getUser, getTeamForUser } from '@/lib/db/queries'; +import { redirect } from 'next/navigation'; + +export default async function CreditsPage() { + const user = await getUser(); + if (!user) { + redirect('/sign-in'); + } + + const team = await getTeamForUser(user.id); + if (!team) { + throw new Error('Team not found'); + } + + return ( +
+

Credit Management

+ +
+ ); +} diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index 19e170a9d..182e93d69 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -1,8 +1,9 @@ import { redirect } from 'next/navigation'; import { Settings } from './settings'; import { getTeamForUser, getUser } from '@/lib/db/queries'; +import { CreditBalance } from '@/components/credits/credit-components'; -export default async function SettingsPage() { +export default async function DashboardPage() { const user = await getUser(); if (!user) { @@ -15,5 +16,16 @@ export default async function SettingsPage() { throw new Error('Team not found'); } - return ; + return ( +
+
+
+ +
+
+ +
+
+
+ ); } diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index e33b0df06..812b4bd61 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -3,7 +3,7 @@ import Link from 'next/link'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; -import { CircleIcon, Home, LogOut } from 'lucide-react'; +import { CircleIcon, Home, LogOut, CreditCard } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, @@ -60,6 +60,12 @@ function Header() { Dashboard + + + + Credits + +
+ ); +} diff --git a/components/credits/credit-components.tsx b/components/credits/credit-components.tsx new file mode 100644 index 000000000..ed5e33d81 --- /dev/null +++ b/components/credits/credit-components.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Progress } from '@/components/ui/progress'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +interface CreditBalanceData { + balance: number; + limit: number; + usage: number; +} + +export function CreditBalance({ teamId }: { teamId: number }) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch(`/api/credits/balance?teamId=${teamId}`) + .then((res) => res.json()) + .then((data) => { + setData(data); + setLoading(false); + }); + }, [teamId]); + + if (loading) { + return
Loading credits...
; + } + + if (!data) { + return
Could not load credit balance.
; + } + + const percentage = data.limit > 0 ? (data.balance / data.limit) * 100 : 0; + + return ( + + + Credit Balance + Your available credits for this period. + + +
{data.balance.toLocaleString()}
+

+ out of {data.limit.toLocaleString()} +

+ +
+
+ ); +} + +export function CreditDashboard({ teamId }: { teamId: number }) { + return ( +
+ + + +
+ ); +} + +function UsageHistory({ teamId }: { teamId: number }) { + const [history, setHistory] = useState([]); + useEffect(() => { + fetch(`/api/credits/usage-history?teamId=${teamId}&limit=10`) + .then((res) => res.json()) + .then(setHistory); + }, [teamId]); + + return ( + + + Usage History + + + + + + Date + Action + Credits Used + + + + {history.map((item: any) => ( + + + {new Date(item.createdAt).toLocaleDateString()} + + {item.actionType} + {item.creditsUsed} + + ))} + +
+
+
+ ); +} + +function GrantsHistory({ teamId }: { teamId: number }) { + const [history, setHistory] = useState([]); + useEffect(() => { + fetch(`/api/credits/grants-history?teamId=${teamId}&limit=10`) + .then((res) => res.json()) + .then(setHistory); + }, [teamId]); + + return ( + + + Grants History + + + + + + Date + Reason + Credits Granted + + + + {history.map((item: any) => ( + + + {new Date(item.createdAt).toLocaleDateString()} + + {item.grantReason} + {item.creditsGranted} + + ))} + +
+
+
+ ); +} + +export function useCreditCheck(teamId: number, requiredCredits: number) { + const [hasSufficientCredits, setHasSufficientCredits] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (teamId) { + fetch(`/api/credits/balance?teamId=${teamId}`) + .then((res) => res.json()) + .then((data) => { + setHasSufficientCredits(data.balance >= requiredCredits); + setLoading(false); + }); + } + }, [teamId, requiredCredits]); + + return { hasSufficientCredits, loading }; +} diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 1d047ce66..286529883 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -29,6 +29,12 @@ export const teams = pgTable('teams', { stripeProductId: text('stripe_product_id'), planName: varchar('plan_name', { length: 50 }), subscriptionStatus: varchar('subscription_status', { length: 20 }), + creditBalance: integer('credit_balance').default(0).notNull(), + creditLimit: integer('credit_limit').default(0).notNull(), + creditsGrantedThisPeriod: integer('credits_granted_this_period').default(0).notNull(), + billingPeriodStart: timestamp('billing_period_start'), + billingPeriodEnd: timestamp('billing_period_end'), + stripeMeterId: varchar('stripe_meter_id', { length: 255 }), }); export const teamMembers = pgTable('team_members', { @@ -140,3 +146,26 @@ export enum ActivityType { INVITE_TEAM_MEMBER = 'INVITE_TEAM_MEMBER', ACCEPT_INVITATION = 'ACCEPT_INVITATION', } + +export const creditUsageLogs = pgTable('credit_usage_logs', { + id: serial('id').primaryKey(), + teamId: integer('team_id').references(() => teams.id), + userId: integer('user_id').references(() => users.id), + creditsUsed: integer('credits_used').notNull(), + actionType: varchar('action_type', { length: 100 }).notNull(), + description: text('description'), + metadata: text('metadata'), + createdAt: timestamp('created_at').defaultNow(), +}); + +export const creditGrantsLogs = pgTable('credit_grants_logs', { + id: serial('id').primaryKey(), + teamId: integer('team_id').references(() => teams.id), + creditsGranted: integer('credits_granted').notNull(), + grantReason: varchar('grant_reason', { length: 100 }).notNull(), + stripeGrantId: varchar('stripe_grant_id', { length: 255 }), + billingPeriodStart: timestamp('billing_period_start'), + billingPeriodEnd: timestamp('billing_period_end'), + metadata: text('metadata'), + createdAt: timestamp('created_at').defaultNow(), +}); diff --git a/lib/services/credit-service.ts b/lib/services/credit-service.ts new file mode 100644 index 000000000..1a1de977b --- /dev/null +++ b/lib/services/credit-service.ts @@ -0,0 +1,190 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { and, eq } from 'drizzle-orm'; +import { db } from '@/lib/db/drizzle'; +import { + teams, + creditUsageLogs, + creditGrantsLogs, + Team, +} from '@/lib/db/schema'; +import { stripe } from '@/lib/payments/stripe'; +import { InsufficientCreditsError } from '@/lib/utils'; + +const CREDIT_PLANS: Record = { + price_1PDRpL2M4y2B2g5GS2Z8o42j: { credits: 100, name: 'Basic' }, + price_1PDRpL2M4y2B2g5Gj2W7s8x7: { credits: 500, name: 'Pro' }, + price_1PDRpL2M4y2B2g5Gk8f6h9Y2: { credits: 2000, name: 'Enterprise' }, + price_1PDRpL2M4y2B2g5GZ2x8o42j: { credits: 1200, name: 'Basic (Yearly)' }, + price_1PDRpL2M4y2B2g5GT2W7s8x7: { credits: 6000, name: 'Pro (Yearly)' }, + price_1PDRpL2M4y2B2g5Gh8f6h9Y2: { + credits: 24000, + name: 'Enterprise (Yearly)', + }, +}; + +class CreditService { + async getCreditBalance(teamId: number) { + const team = await this.getTeam(teamId); + return { + balance: team.creditBalance, + limit: team.creditLimit, + usage: team.creditLimit - team.creditBalance, + }; + } + + async useCredits( + teamId: number, + userId: number | null, + credits: number, + actionType: string, + description?: string, + metadata?: Record + ) { + const team = await this.getTeam(teamId); + + if (team.creditBalance < credits) { + throw new InsufficientCreditsError(); + } + + const newBalance = team.creditBalance - credits; + + await db + .update(teams) + .set({ creditBalance: newBalance }) + .where(eq(teams.id, teamId)); + + await db.insert(creditUsageLogs).values({ + teamId, + userId, + creditsUsed: credits, + actionType, + description, + metadata: JSON.stringify(metadata), + }); + + if (team.stripeCustomerId && team.stripeMeterId) { + try { + await stripe.billing.meterEvents.create({ + meter: team.stripeMeterId, + value: credits, + timestamp: Math.floor(Date.now() / 1000), + customer: team.stripeCustomerId, + metadata: { + team_id: team.id, + user_id: userId, + action_type: actionType, + }, + }); + } catch (error) { + console.error('Failed to report usage to Stripe:', error); + // Optional: Implement retry logic or alert system + } + } + + return this.getCreditBalance(teamId); + } + + async grantCreditsForSubscription( + subscription: any, + grantReason = 'subscription_payment' + ) { + const team = await this.getTeamByStripeCustomerId(subscription.customer); + if (!team) return; + + const planItem = subscription.items.data.find( + (item: any) => item.price.recurring + ); + if (!planItem) return; + + const planId = planItem.price.id; + const plan = CREDIT_PLANS[planId]; + if (!plan) return; + + const creditsToGrant = plan.credits; + const billingPeriodStart = new Date(subscription.current_period_start * 1000); + const billingPeriodEnd = new Date(subscription.current_period_end * 1000); + + const newCreditLimit = creditsToGrant; + const newCreditBalance = newCreditLimit; // Reset balance to full limit + + await db + .update(teams) + .set({ + creditBalance: newCreditBalance, + creditLimit: newCreditLimit, + creditsGrantedThisPeriod: creditsToGrant, + billingPeriodStart, + billingPeriodEnd, + stripeMeterId: process.env.STRIPE_CREDIT_METER_ID, + planName: plan.name, + }) + .where(eq(teams.id, team.id)); + + await db.insert(creditGrantsLogs).values({ + teamId: team.id, + creditsGranted: creditsToGrant, + grantReason, + billingPeriodStart, + billingPeriodEnd, + metadata: JSON.stringify({ subscriptionId: subscription.id }), + }); + + return this.getCreditBalance(team.id); + } + + async resetCreditsForCanceledSubscription(subscription: any) { + const team = await this.getTeamByStripeCustomerId(subscription.customer); + if (!team) return; + + await db + .update(teams) + .set({ + creditBalance: 0, + creditLimit: 0, + creditsGrantedThisPeriod: 0, + billingPeriodStart: null, + billingPeriodEnd: null, + }) + .where(eq(teams.id, team.id)); + } + + async getUsageHistory(teamId: number, limit = 20, offset = 0) { + return db + .select() + .from(creditUsageLogs) + .where(eq(creditUsageLogs.teamId, teamId)) + .orderBy(creditUsageLogs.createdAt) + .limit(limit) + .offset(offset); + } + + async getGrantsHistory(teamId: number, limit = 20, offset = 0) { + return db + .select() + .from(creditGrantsLogs) + .where(eq(creditGrantsLogs.teamId, teamId)) + .orderBy(creditGrantsLogs.createdAt) + .limit(limit) + .offset(offset); + } + + private async getTeam(teamId: number): Promise { + const results = await db.select().from(teams).where(eq(teams.id, teamId)); + if (results.length === 0) { + throw new Error(`Team with ID ${teamId} not found`); + } + return results[0]; + } + + private async getTeamByStripeCustomerId( + customerId: string + ): Promise { + const results = await db + .select() + .from(teams) + .where(eq(teams.stripeCustomerId, customerId)); + return results[0] || null; + } +} + +export const creditService = new CreditService(); diff --git a/lib/services/stripe-webhook-handler.ts b/lib/services/stripe-webhook-handler.ts new file mode 100644 index 000000000..6192d874a --- /dev/null +++ b/lib/services/stripe-webhook-handler.ts @@ -0,0 +1,44 @@ +import Stripe from 'stripe'; +import { NextRequest, NextResponse } from 'next/server'; +import { stripe } from '@/lib/payments/stripe'; +import { creditService } from './credit-service'; + +const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!; + +export async function handleStripeWebhook(request: NextRequest) { + const payload = await request.text(); + const signature = request.headers.get('stripe-signature') as string; + + let event: Stripe.Event; + + try { + event = stripe.webhooks.constructEvent(payload, signature, webhookSecret); + } catch (err) { + console.error('Webhook signature verification failed.', err); + return NextResponse.json( + { error: 'Webhook signature verification failed.' }, + { status: 400 } + ); + } + + const subscription = event.data.object as Stripe.Subscription; + + switch (event.type) { + case 'invoice.paid': + if ((event.data.object as Stripe.Invoice).billing_reason === 'subscription_cycle') { + await creditService.grantCreditsForSubscription(subscription); + } + break; + case 'customer.subscription.created': + case 'customer.subscription.updated': + await creditService.grantCreditsForSubscription(subscription, 'subscription_update'); + break; + case 'customer.subscription.deleted': + await creditService.resetCreditsForCanceledSubscription(subscription); + break; + default: + console.log(`Unhandled event type ${event.type}`); + } + + return NextResponse.json({ received: true }); +} diff --git a/lib/utils.ts b/lib/utils.ts index 2819a830d..ab9135fa1 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -4,3 +4,10 @@ import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export class InsufficientCreditsError extends Error { + constructor(message = 'Insufficient credits') { + super(message); + this.name = 'InsufficientCreditsError'; + } +} diff --git a/scripts/stripe-setup.js b/scripts/stripe-setup.js new file mode 100644 index 000000000..8960a0986 --- /dev/null +++ b/scripts/stripe-setup.js @@ -0,0 +1,247 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +/* eslint-disable no-console */ +const Stripe = require('stripe'); + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); + +const CREDIT_PLANS = { + price_basic_monthly: { credits: 100, name: 'Basic' }, + price_pro_monthly: { credits: 500, name: 'Pro' }, + price_enterprise_monthly: { credits: 2000, name: 'Enterprise' }, + price_basic_yearly: { credits: 1200, name: 'Basic (Yearly)' }, + price_pro_yearly: { credits: 6000, name: 'Pro (Yearly)' }, + price_enterprise_yearly: { credits: 24000, name: 'Enterprise (Yearly)' }, +}; + +async function setup() { + console.log('Setting up Stripe billing meter...'); + + try { + const meter = await stripe.billing.meters.create({ + name: 'SaaS Credits', + default_aggregation: { + formula: 'sum', + }, + customer_mapping: { + type: 'by_id', + value_path: 'stripe_customer_id', + }, + }); + + console.log('✅ Billing meter created:'); + console.log(`Meter ID: ${meter.id}`); + console.log('Add this to your .env file:'); + console.log(`STRIPE_CREDIT_METER_ID=${meter.id}`); + + await createMeteredPrices(meter.id); + + console.log('\nSetup complete!'); + } catch (error) { + if (error.code === 'resource_already_exists') { + console.warn('⚠️ Billing meter "SaaS Credits" already exists.'); + const meters = await stripe.billing.meters.list({ active: true }); + const existingMeter = meters.data.find( + (m) => m.display_name === 'SaaS Credits' + ); + if (existingMeter) { + console.log(`Using existing meter ID: ${existingMeter.id}`); + console.log('Add this to your .env file if not already present:'); + console.log(`STRIPE_CREDIT_METER_ID=${existingMeter.id}`); + await createMeteredPrices(existingMeter.id); + } + } else { + console.error('Error setting up Stripe billing meter:', error.message); + throw error; + } + } +} + +async function createMeteredPrices(meterId) { + console.log('\nCreating metered prices for existing products...'); + try { + const products = await stripe.products.list({ active: true }); + for (const product of products.data) { + const prices = await stripe.prices.list({ + product: product.id, + active: true, + }); + for (const price of prices.data) { + if (price.recurring && !price.transform_quantity) { + try { + const meteredPrice = await stripe.prices.create({ + product: product.id, + currency: price.currency, + unit_amount: 0, // No charge per credit, just metering + recurring: { + interval: price.recurring.interval, + interval_count: price.recurring.interval_count, + usage_type: 'metered', + }, + billing_scheme: 'per_unit', + nickname: `${price.nickname} (Metered)`, + metadata: { + parent_price_id: price.id, + is_metered_price: 'true', + }, + tax_behavior: 'unspecified', + }); + console.log( + `✅ Metered price created for ${product.name} (${price.nickname})` + ); + console.log(` - Price ID: ${meteredPrice.id}`); + } catch (priceError) { + if (priceError.code === 'resource_already_exists') { + console.warn( + ` - ⚠️ Metered price for ${product.name} (${price.nickname}) already exists.` + ); + } else { + console.error( + ` - ❌ Error creating metered price for ${product.name}:`, + priceError.message + ); + } + } + } + } + } + } catch (error) { + console.error('Error creating metered prices:', error.message); + } +} + +async function fullSetup() { + console.log('Starting full setup for existing subscriptions...'); + await setup(); // Ensure meter and prices exist + + const meterId = process.env.STRIPE_CREDIT_METER_ID; + if (!meterId) { + console.error( + 'STRIPE_CREDIT_METER_ID not found in environment variables. Run `setup` first.' + ); + return; + } + + console.log('\nUpdating existing subscriptions with metered price...'); + try { + const subscriptions = await stripe.subscriptions.list({ + status: 'active', + limit: 100, + }); + + for (const subscription of subscriptions.data) { + const hasMeteredPrice = subscription.items.data.some( + (item) => item.price.recurring?.usage_type === 'metered' + ); + + if (hasMeteredPrice) { + console.log( + `- Subscription ${subscription.id} already has a metered price. Skipping.` + ); + continue; + } + + const flatRateItem = subscription.items.data.find( + (item) => item.price.recurring?.usage_type !== 'metered' + ); + if (!flatRateItem) { + console.warn( + `- Subscription ${subscription.id} has no flat-rate item. Skipping.` + ); + continue; + } + + const meteredPrices = await stripe.prices.list({ + product: flatRateItem.price.product, + active: true, + metadata: { parent_price_id: flatRateItem.price.id }, + }); + const meteredPrice = meteredPrices.data[0]; + + if (!meteredPrice) { + console.error( + `- ❌ No metered price found for product ${flatRateItem.price.product}. Skipping subscription ${subscription.id}.` + ); + continue; + } + + await stripe.subscriptions.update(subscription.id, { + items: [ + ...subscription.items.data.map((item) => ({ + id: item.id, + })), + { + price: meteredPrice.id, + }, + ], + proration_behavior: 'none', + }); + console.log(`✅ Subscription ${subscription.id} updated.`); + + // Grant initial credits + const planKey = Object.keys(CREDIT_PLANS).find( + (key) => key === flatRateItem.price.id + ); + if (planKey) { + const credits = CREDIT_PLANS[planKey].credits; + await grantCredits(subscription.customer, credits, 'initial_grant'); + console.log( + ` - Granted ${credits} initial credits to customer ${subscription.customer}` + ); + } + } + } catch (error) { + console.error('Error updating subscriptions:', error.message); + } +} + +async function grantCredits(customerId, credits, grantReason) { + const meterId = process.env.STRIPE_CREDIT_METER_ID; + if (!meterId) { + console.error('STRIPE_CREDIT_METER_ID not set.'); + return; + } + try { + await stripe.billing.meterEvents.create({ + meter: meterId, + value: credits, + timestamp: Math.floor(Date.now() / 1000), + customer: customerId, + metadata: { + grant_reason: grantReason, + }, + }); + console.log(`Granted ${credits} credits to customer ${customerId}`); + } catch (error) { + console.error( + `Error granting credits to customer ${customerId}:`, + error.message + ); + } +} + +async function testGrant(customerId, credits) { + if (!customerId || !credits) { + console.error('Usage: node stripe-setup.js test-grant '); + return; + } + console.log(`Granting ${credits} test credits to customer ${customerId}...`); + await grantCredits(customerId, parseInt(credits, 10), 'test_grant'); +} + +const command = process.argv[2]; +const args = process.argv.slice(3); + +switch (command) { + case 'setup': + setup(); + break; + case 'full-setup': + fullSetup(); + break; + case 'test-grant': + testGrant(...args); + break; + default: + console.log('Usage: node stripe-setup.js '); + console.log('Commands: setup, full-setup, test-grant'); +}