Add Stripe payment integration for AI subscriptions
Implement subscription-based AI access with 250 generations/month at $5/month or $50/year. Changes: - Backend: Stripe service, payment routes, webhook handlers, generation tracking - Frontend: Upgrade page with pricing, payment success/cancel pages, UI prompts - Database: Add subscription fields to users, payments table, migrations - Config: Stripe env vars to .env.example, docker-compose.prod.yml, PRODUCTION.md - Tests: Payment route tests, component tests, subscription hook tests Users without AI access see upgrade prompts; subscribers see remaining generation count.
This commit is contained in:
parent
3c54a0f4d9
commit
2e12edc249
22 changed files with 2866 additions and 21 deletions
23
server/package-lock.json
generated
23
server/package-lock.json
generated
|
|
@ -18,6 +18,7 @@
|
|||
"jwks-rsa": "^3.1.0",
|
||||
"multer": "^2.0.2",
|
||||
"officeparser": "^6.0.4",
|
||||
"stripe": "^20.2.0",
|
||||
"uuid": "^11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -1454,7 +1455,6 @@
|
|||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
|
|
@ -2896,6 +2896,26 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stripe": {
|
||||
"version": "20.2.0",
|
||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.2.0.tgz",
|
||||
"integrity": "sha512-m8niTfdm3nPP/yQswRWMwQxqEUcTtB3RTJQ9oo6NINDzgi7aPOadsH/fPXIIfL1Sc5+lqQFKSk7WiO6CXmvaeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"qs": "^6.14.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=16"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/strtok3": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz",
|
||||
|
|
@ -3205,7 +3225,6 @@
|
|||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"jwks-rsa": "^3.1.0",
|
||||
"multer": "^2.0.2",
|
||||
"officeparser": "^6.0.4",
|
||||
"stripe": "^20.2.0",
|
||||
"uuid": "^11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,69 @@ const runMigrations = () => {
|
|||
db.exec("CREATE UNIQUE INDEX idx_quizzes_share_token ON quizzes(share_token)");
|
||||
console.log("Migration: Created unique index on quizzes.share_token");
|
||||
}
|
||||
|
||||
const userTableInfo3 = db.prepare("PRAGMA table_info(users)").all() as { name: string }[];
|
||||
|
||||
const hasStripeCustomerId = userTableInfo3.some(col => col.name === "stripe_customer_id");
|
||||
if (!hasStripeCustomerId) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN stripe_customer_id TEXT UNIQUE");
|
||||
console.log("Migration: Added stripe_customer_id to users");
|
||||
}
|
||||
|
||||
const hasSubscriptionStatus = userTableInfo3.some(col => col.name === "subscription_status");
|
||||
if (!hasSubscriptionStatus) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN subscription_status TEXT DEFAULT 'none'");
|
||||
console.log("Migration: Added subscription_status to users");
|
||||
}
|
||||
|
||||
const hasSubscriptionId = userTableInfo3.some(col => col.name === "subscription_id");
|
||||
if (!hasSubscriptionId) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN subscription_id TEXT");
|
||||
console.log("Migration: Added subscription_id to users");
|
||||
}
|
||||
|
||||
const hasSubscriptionPeriodEnd = userTableInfo3.some(col => col.name === "subscription_current_period_end");
|
||||
if (!hasSubscriptionPeriodEnd) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN subscription_current_period_end DATETIME");
|
||||
console.log("Migration: Added subscription_current_period_end to users");
|
||||
}
|
||||
|
||||
const hasGenerationCount = userTableInfo3.some(col => col.name === "generation_count");
|
||||
if (!hasGenerationCount) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN generation_count INTEGER DEFAULT 0");
|
||||
console.log("Migration: Added generation_count to users");
|
||||
}
|
||||
|
||||
const hasGenerationResetDate = userTableInfo3.some(col => col.name === "generation_reset_date");
|
||||
if (!hasGenerationResetDate) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN generation_reset_date DATETIME");
|
||||
console.log("Migration: Added generation_reset_date to users");
|
||||
}
|
||||
|
||||
const paymentsTable = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='payments'").get();
|
||||
if (!paymentsTable) {
|
||||
db.exec(`
|
||||
CREATE TABLE payments (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
stripe_payment_intent_id TEXT,
|
||||
stripe_invoice_id TEXT,
|
||||
amount INTEGER NOT NULL,
|
||||
currency TEXT DEFAULT 'usd',
|
||||
status TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_payments_user ON payments(user_id);
|
||||
`);
|
||||
console.log("Migration: Created payments table");
|
||||
}
|
||||
|
||||
const stripeCustomerIndex = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_users_stripe_customer'").get();
|
||||
if (!stripeCustomerIndex) {
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_users_stripe_customer ON users(stripe_customer_id)");
|
||||
console.log("Migration: Created index on users.stripe_customer_id");
|
||||
}
|
||||
};
|
||||
|
||||
runMigrations();
|
||||
|
|
|
|||
|
|
@ -7,7 +7,15 @@ CREATE TABLE IF NOT EXISTS users (
|
|||
last_login DATETIME,
|
||||
default_game_config TEXT,
|
||||
color_scheme TEXT DEFAULT 'blue',
|
||||
gemini_api_key TEXT
|
||||
gemini_api_key TEXT,
|
||||
-- Stripe subscription fields
|
||||
stripe_customer_id TEXT UNIQUE,
|
||||
subscription_status TEXT DEFAULT 'none',
|
||||
subscription_id TEXT,
|
||||
subscription_current_period_end DATETIME,
|
||||
-- Generation tracking
|
||||
generation_count INTEGER DEFAULT 0,
|
||||
generation_reset_date DATETIME
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS quizzes (
|
||||
|
|
@ -62,3 +70,18 @@ CREATE TABLE IF NOT EXISTS game_sessions (
|
|||
CREATE INDEX IF NOT EXISTS idx_quizzes_user ON quizzes(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_questions_quiz ON questions(quiz_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_options_question ON answer_options(question_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payments (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
stripe_payment_intent_id TEXT,
|
||||
stripe_invoice_id TEXT,
|
||||
amount INTEGER NOT NULL,
|
||||
currency TEXT DEFAULT 'usd',
|
||||
status TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_payments_user ON payments(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_stripe_customer ON users(stripe_customer_id);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import uploadRouter from './routes/upload.js';
|
|||
import gamesRouter from './routes/games.js';
|
||||
import generateRouter from './routes/generate.js';
|
||||
import sharedRouter from './routes/shared.js';
|
||||
import paymentsRouter, { webhookHandler } from './routes/payments.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
|
@ -58,6 +59,8 @@ app.use((req: Request, res: Response, next: NextFunction) => {
|
|||
next();
|
||||
});
|
||||
|
||||
app.post('/api/payments/webhook', express.raw({ type: 'application/json' }), webhookHandler);
|
||||
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
express.json({ limit: '10mb' })(req, res, (err) => {
|
||||
if (err instanceof SyntaxError && 'body' in err) {
|
||||
|
|
@ -95,6 +98,7 @@ app.use('/api/upload', uploadRouter);
|
|||
app.use('/api/games', gamesRouter);
|
||||
app.use('/api/generate', generateRouter);
|
||||
app.use('/api/shared', sharedRouter);
|
||||
app.use('/api/payments', paymentsRouter);
|
||||
|
||||
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
||||
console.error('Unhandled error:', err);
|
||||
|
|
|
|||
|
|
@ -83,6 +83,13 @@ export function requireAuth(
|
|||
);
|
||||
}
|
||||
|
||||
import { canGenerate, incrementGenerationCount, GENERATION_LIMIT } from '../services/stripe.js';
|
||||
|
||||
export interface AIAccessInfo {
|
||||
accessType: 'group' | 'subscription' | 'none';
|
||||
remaining?: number;
|
||||
}
|
||||
|
||||
export function requireAIAccess(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
|
|
@ -93,12 +100,44 @@ export function requireAIAccess(
|
|||
return;
|
||||
}
|
||||
|
||||
const hasAccess = req.user.groups?.includes('kaboot-ai-access');
|
||||
const groups = req.user.groups || [];
|
||||
const result = canGenerate(req.user.sub, groups);
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({ error: 'AI access not granted for this account' });
|
||||
if (!result.allowed) {
|
||||
res.status(403).json({
|
||||
error: result.reason || 'AI access not granted for this account',
|
||||
remaining: result.remaining,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
(req as any).aiAccessInfo = {
|
||||
accessType: groups.includes('kaboot-ai-access') ? 'group' : 'subscription',
|
||||
remaining: result.remaining,
|
||||
} as AIAccessInfo;
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export function trackGeneration(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
if (!req.user) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = req.user.groups || [];
|
||||
if (groups.includes('kaboot-ai-access')) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const newCount = incrementGenerationCount(req.user.sub);
|
||||
const remaining = Math.max(0, GENERATION_LIMIT - newCount);
|
||||
|
||||
res.setHeader('X-Generations-Remaining', remaining.toString());
|
||||
next();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Router, Response } from 'express';
|
||||
import { GoogleGenAI, Type, createUserContent, createPartFromUri } from '@google/genai';
|
||||
import { requireAuth, AuthenticatedRequest, requireAIAccess } from '../middleware/auth.js';
|
||||
import { incrementGenerationCount, GENERATION_LIMIT } from '../services/stripe.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const router = Router();
|
||||
|
|
@ -170,6 +171,13 @@ router.post('/', requireAuth, requireAIAccess, async (req: AuthenticatedRequest,
|
|||
const data = JSON.parse(response.text);
|
||||
const quiz = transformToQuiz(data);
|
||||
|
||||
const groups = req.user!.groups || [];
|
||||
if (!groups.includes('kaboot-ai-access')) {
|
||||
const newCount = incrementGenerationCount(req.user!.sub);
|
||||
const remaining = Math.max(0, GENERATION_LIMIT - newCount);
|
||||
res.setHeader('X-Generations-Remaining', remaining.toString());
|
||||
}
|
||||
|
||||
res.json(quiz);
|
||||
} catch (err: any) {
|
||||
console.error('AI generation error:', err);
|
||||
|
|
|
|||
282
server/src/routes/payments.ts
Normal file
282
server/src/routes/payments.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
import { Router, Response, Request } from 'express';
|
||||
import Stripe from 'stripe';
|
||||
import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js';
|
||||
import { db } from '../db/connection.js';
|
||||
import {
|
||||
getStripe,
|
||||
isStripeConfigured,
|
||||
createCheckoutSession,
|
||||
createPortalSession,
|
||||
getSubscriptionStatus,
|
||||
activateSubscription,
|
||||
updateSubscriptionStatus,
|
||||
resetGenerationCount,
|
||||
recordPayment,
|
||||
GENERATION_LIMIT,
|
||||
} from '../services/stripe.js';
|
||||
|
||||
const router = Router();
|
||||
const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
|
||||
router.get('/config', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
configured: isStripeConfigured(),
|
||||
generationLimit: GENERATION_LIMIT,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/status', requireAuth, (req: AuthenticatedRequest, res: Response) => {
|
||||
const userId = req.user!.sub;
|
||||
const groups = req.user!.groups || [];
|
||||
const hasGroupAccess = groups.includes('kaboot-ai-access');
|
||||
|
||||
if (hasGroupAccess) {
|
||||
res.json({
|
||||
hasAccess: true,
|
||||
accessType: 'group',
|
||||
status: 'active',
|
||||
generationCount: 0,
|
||||
generationLimit: null,
|
||||
generationsRemaining: null,
|
||||
currentPeriodEnd: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const status = getSubscriptionStatus(userId);
|
||||
|
||||
res.json({
|
||||
hasAccess: status.status === 'active',
|
||||
accessType: status.status === 'active' ? 'subscription' : 'none',
|
||||
status: status.status,
|
||||
generationCount: status.generationCount,
|
||||
generationLimit: status.generationLimit,
|
||||
generationsRemaining: status.generationsRemaining,
|
||||
currentPeriodEnd: status.currentPeriodEnd,
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/checkout', requireAuth, async (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!isStripeConfigured()) {
|
||||
res.status(503).json({ error: 'Payments are not configured' });
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = req.user!.sub;
|
||||
const email = req.user!.email;
|
||||
const { planType, successUrl, cancelUrl } = req.body;
|
||||
|
||||
if (!planType || !['monthly', 'yearly'].includes(planType)) {
|
||||
res.status(400).json({ error: 'Invalid plan type. Must be "monthly" or "yearly".' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!successUrl || !cancelUrl) {
|
||||
res.status(400).json({ error: 'successUrl and cancelUrl are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await createCheckoutSession(userId, email, planType, successUrl, cancelUrl);
|
||||
res.json({ url: session.url });
|
||||
} catch (err: any) {
|
||||
console.error('Checkout session error:', err);
|
||||
res.status(500).json({ error: err.message || 'Failed to create checkout session' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/portal', requireAuth, async (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!isStripeConfigured()) {
|
||||
res.status(503).json({ error: 'Payments are not configured' });
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = req.user!.sub;
|
||||
const { returnUrl } = req.body;
|
||||
|
||||
if (!returnUrl) {
|
||||
res.status(400).json({ error: 'returnUrl is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await createPortalSession(userId, returnUrl);
|
||||
res.json({ url: session.url });
|
||||
} catch (err: any) {
|
||||
console.error('Portal session error:', err);
|
||||
res.status(500).json({ error: err.message || 'Failed to create portal session' });
|
||||
}
|
||||
});
|
||||
|
||||
function getUserIdFromCustomer(customerId: string): string | null {
|
||||
const user = db.prepare('SELECT id FROM users WHERE stripe_customer_id = ?').get(customerId) as { id: string } | undefined;
|
||||
return user?.id || null;
|
||||
}
|
||||
|
||||
async function handleCheckoutCompleted(session: Stripe.Checkout.Session): Promise<void> {
|
||||
const userId = session.metadata?.user_id;
|
||||
if (!userId) {
|
||||
console.error('No user_id in checkout session metadata');
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.mode === 'subscription' && session.subscription) {
|
||||
const stripe = getStripe();
|
||||
const subscription = await stripe.subscriptions.retrieve(session.subscription as string);
|
||||
|
||||
const firstItem = subscription.items.data[0];
|
||||
const periodEnd = new Date(firstItem.current_period_end * 1000);
|
||||
activateSubscription(userId, subscription.id, periodEnd);
|
||||
|
||||
console.log(`Subscription activated for user ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubscriptionUpdated(subscription: Stripe.Subscription): Promise<void> {
|
||||
const userId = subscription.metadata?.user_id || getUserIdFromCustomer(subscription.customer as string);
|
||||
if (!userId) {
|
||||
console.error('Could not find user for subscription:', subscription.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const firstItem = subscription.items.data[0];
|
||||
const periodEnd = firstItem ? new Date(firstItem.current_period_end * 1000) : new Date();
|
||||
|
||||
switch (subscription.status) {
|
||||
case 'active':
|
||||
updateSubscriptionStatus(userId, 'active', periodEnd);
|
||||
break;
|
||||
case 'past_due':
|
||||
updateSubscriptionStatus(userId, 'past_due', periodEnd);
|
||||
break;
|
||||
case 'canceled':
|
||||
case 'unpaid':
|
||||
updateSubscriptionStatus(userId, 'canceled');
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`Subscription ${subscription.id} updated to ${subscription.status} for user ${userId}`);
|
||||
}
|
||||
|
||||
async function handleSubscriptionDeleted(subscription: Stripe.Subscription): Promise<void> {
|
||||
const userId = subscription.metadata?.user_id || getUserIdFromCustomer(subscription.customer as string);
|
||||
if (!userId) {
|
||||
console.error('Could not find user for subscription:', subscription.id);
|
||||
return;
|
||||
}
|
||||
|
||||
updateSubscriptionStatus(userId, 'canceled');
|
||||
console.log(`Subscription ${subscription.id} deleted for user ${userId}`);
|
||||
}
|
||||
|
||||
async function handleInvoicePaid(invoice: Stripe.Invoice): Promise<void> {
|
||||
const customerId = invoice.customer as string;
|
||||
const userId = getUserIdFromCustomer(customerId);
|
||||
|
||||
if (!userId) {
|
||||
console.error('Could not find user for customer:', customerId);
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionId = invoice.parent?.subscription_details?.subscription;
|
||||
if (subscriptionId) {
|
||||
const stripe = getStripe();
|
||||
const subId = typeof subscriptionId === 'string' ? subscriptionId : subscriptionId.id;
|
||||
const subscription = await stripe.subscriptions.retrieve(subId);
|
||||
const firstItem = subscription.items.data[0];
|
||||
const periodEnd = firstItem ? new Date(firstItem.current_period_end * 1000) : new Date();
|
||||
|
||||
resetGenerationCount(userId, periodEnd);
|
||||
updateSubscriptionStatus(userId, 'active', periodEnd);
|
||||
|
||||
console.log(`Generation count reset for user ${userId} (invoice paid)`);
|
||||
}
|
||||
|
||||
const invoiceAny = invoice as any;
|
||||
recordPayment(
|
||||
userId,
|
||||
invoiceAny.payment_intent || null,
|
||||
invoice.id,
|
||||
invoice.amount_paid,
|
||||
invoice.currency,
|
||||
'succeeded',
|
||||
invoice.description || 'Subscription payment'
|
||||
);
|
||||
}
|
||||
|
||||
async function handleInvoicePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
|
||||
const customerId = invoice.customer as string;
|
||||
const userId = getUserIdFromCustomer(customerId);
|
||||
|
||||
if (!userId) {
|
||||
console.error('Could not find user for customer:', customerId);
|
||||
return;
|
||||
}
|
||||
|
||||
updateSubscriptionStatus(userId, 'past_due');
|
||||
console.log(`Payment failed for user ${userId}, status set to past_due`);
|
||||
|
||||
const invoiceAny = invoice as any;
|
||||
recordPayment(
|
||||
userId,
|
||||
invoiceAny.payment_intent || null,
|
||||
invoice.id,
|
||||
invoice.amount_due,
|
||||
invoice.currency,
|
||||
'failed',
|
||||
'Payment failed'
|
||||
);
|
||||
}
|
||||
|
||||
export const webhookHandler = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!STRIPE_WEBHOOK_SECRET) {
|
||||
res.status(503).json({ error: 'Webhook secret not configured' });
|
||||
return;
|
||||
}
|
||||
|
||||
const sig = req.headers['stripe-signature'];
|
||||
if (!sig) {
|
||||
res.status(400).json({ error: 'Missing stripe-signature header' });
|
||||
return;
|
||||
}
|
||||
|
||||
let event: Stripe.Event;
|
||||
|
||||
try {
|
||||
const stripe = getStripe();
|
||||
event = stripe.webhooks.constructEvent(req.body, sig, STRIPE_WEBHOOK_SECRET);
|
||||
} catch (err: any) {
|
||||
console.error('Webhook signature verification failed:', err.message);
|
||||
res.status(400).json({ error: `Webhook Error: ${err.message}` });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed':
|
||||
await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
|
||||
break;
|
||||
case 'customer.subscription.updated':
|
||||
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
|
||||
break;
|
||||
case 'customer.subscription.deleted':
|
||||
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
|
||||
break;
|
||||
case 'invoice.paid':
|
||||
await handleInvoicePaid(event.data.object as Stripe.Invoice);
|
||||
break;
|
||||
case 'invoice.payment_failed':
|
||||
await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice);
|
||||
break;
|
||||
default:
|
||||
console.log(`Unhandled event type: ${event.type}`);
|
||||
}
|
||||
|
||||
res.json({ received: true });
|
||||
} catch (err: any) {
|
||||
console.error('Error handling webhook:', err);
|
||||
res.status(500).json({ error: 'Webhook handler failed' });
|
||||
}
|
||||
};
|
||||
|
||||
export default router;
|
||||
242
server/src/services/stripe.ts
Normal file
242
server/src/services/stripe.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import Stripe from 'stripe';
|
||||
import { db } from '../db/connection.js';
|
||||
|
||||
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
||||
const STRIPE_PRICE_ID_MONTHLY = process.env.STRIPE_PRICE_ID_MONTHLY;
|
||||
const STRIPE_PRICE_ID_YEARLY = process.env.STRIPE_PRICE_ID_YEARLY;
|
||||
|
||||
export const GENERATION_LIMIT = 250;
|
||||
|
||||
let stripeClient: Stripe | null = null;
|
||||
|
||||
export function getStripe(): Stripe {
|
||||
if (!stripeClient) {
|
||||
if (!STRIPE_SECRET_KEY) {
|
||||
throw new Error('STRIPE_SECRET_KEY is not configured');
|
||||
}
|
||||
stripeClient = new Stripe(STRIPE_SECRET_KEY);
|
||||
}
|
||||
return stripeClient;
|
||||
}
|
||||
|
||||
export function isStripeConfigured(): boolean {
|
||||
return !!(STRIPE_SECRET_KEY && STRIPE_PRICE_ID_MONTHLY);
|
||||
}
|
||||
|
||||
export function getPriceId(planType: 'monthly' | 'yearly'): string {
|
||||
const priceId = planType === 'yearly' ? STRIPE_PRICE_ID_YEARLY : STRIPE_PRICE_ID_MONTHLY;
|
||||
if (!priceId) {
|
||||
throw new Error(`Price ID for ${planType} plan is not configured`);
|
||||
}
|
||||
return priceId;
|
||||
}
|
||||
|
||||
export async function getOrCreateCustomer(userId: string, email: string | undefined): Promise<string> {
|
||||
const stripe = getStripe();
|
||||
|
||||
const user = db.prepare('SELECT stripe_customer_id FROM users WHERE id = ?').get(userId) as { stripe_customer_id: string | null } | undefined;
|
||||
|
||||
if (user?.stripe_customer_id) {
|
||||
return user.stripe_customer_id;
|
||||
}
|
||||
|
||||
const customer = await stripe.customers.create({
|
||||
email: email || undefined,
|
||||
metadata: {
|
||||
user_id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
db.prepare('UPDATE users SET stripe_customer_id = ? WHERE id = ?').run(customer.id, userId);
|
||||
|
||||
return customer.id;
|
||||
}
|
||||
|
||||
export async function createCheckoutSession(
|
||||
userId: string,
|
||||
email: string | undefined,
|
||||
planType: 'monthly' | 'yearly',
|
||||
successUrl: string,
|
||||
cancelUrl: string
|
||||
): Promise<Stripe.Checkout.Session> {
|
||||
const stripe = getStripe();
|
||||
const customerId = await getOrCreateCustomer(userId, email);
|
||||
const priceId = getPriceId(planType);
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: 'subscription',
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
subscription_data: {
|
||||
metadata: {
|
||||
user_id: userId,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
user_id: userId,
|
||||
plan_type: planType,
|
||||
},
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function createPortalSession(
|
||||
userId: string,
|
||||
returnUrl: string
|
||||
): Promise<Stripe.BillingPortal.Session> {
|
||||
const stripe = getStripe();
|
||||
|
||||
const user = db.prepare('SELECT stripe_customer_id FROM users WHERE id = ?').get(userId) as { stripe_customer_id: string | null } | undefined;
|
||||
|
||||
if (!user?.stripe_customer_id) {
|
||||
throw new Error('No Stripe customer found for this user');
|
||||
}
|
||||
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: user.stripe_customer_id,
|
||||
return_url: returnUrl,
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export interface SubscriptionStatus {
|
||||
status: 'none' | 'active' | 'past_due' | 'canceled';
|
||||
currentPeriodEnd: string | null;
|
||||
generationCount: number;
|
||||
generationLimit: number;
|
||||
generationsRemaining: number;
|
||||
}
|
||||
|
||||
export function getSubscriptionStatus(userId: string): SubscriptionStatus {
|
||||
const user = db.prepare(`
|
||||
SELECT subscription_status, subscription_current_period_end, generation_count, generation_reset_date
|
||||
FROM users WHERE id = ?
|
||||
`).get(userId) as {
|
||||
subscription_status: string | null;
|
||||
subscription_current_period_end: string | null;
|
||||
generation_count: number | null;
|
||||
generation_reset_date: string | null;
|
||||
} | undefined;
|
||||
|
||||
const status = (user?.subscription_status || 'none') as SubscriptionStatus['status'];
|
||||
const generationCount = user?.generation_count || 0;
|
||||
|
||||
return {
|
||||
status,
|
||||
currentPeriodEnd: user?.subscription_current_period_end || null,
|
||||
generationCount,
|
||||
generationLimit: GENERATION_LIMIT,
|
||||
generationsRemaining: Math.max(0, GENERATION_LIMIT - generationCount),
|
||||
};
|
||||
}
|
||||
|
||||
export function activateSubscription(
|
||||
userId: string,
|
||||
subscriptionId: string,
|
||||
currentPeriodEnd: Date
|
||||
): void {
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET subscription_status = 'active',
|
||||
subscription_id = ?,
|
||||
subscription_current_period_end = ?,
|
||||
generation_count = 0,
|
||||
generation_reset_date = ?
|
||||
WHERE id = ?
|
||||
`).run(subscriptionId, currentPeriodEnd.toISOString(), currentPeriodEnd.toISOString(), userId);
|
||||
}
|
||||
|
||||
export function updateSubscriptionStatus(
|
||||
userId: string,
|
||||
status: 'active' | 'past_due' | 'canceled' | 'none',
|
||||
currentPeriodEnd?: Date
|
||||
): void {
|
||||
if (currentPeriodEnd) {
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET subscription_status = ?,
|
||||
subscription_current_period_end = ?
|
||||
WHERE id = ?
|
||||
`).run(status, currentPeriodEnd.toISOString(), userId);
|
||||
} else {
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET subscription_status = ?
|
||||
WHERE id = ?
|
||||
`).run(status, userId);
|
||||
}
|
||||
}
|
||||
|
||||
export function resetGenerationCount(userId: string, newResetDate: Date): void {
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET generation_count = 0,
|
||||
generation_reset_date = ?
|
||||
WHERE id = ?
|
||||
`).run(newResetDate.toISOString(), userId);
|
||||
}
|
||||
|
||||
export function incrementGenerationCount(userId: string): number {
|
||||
const result = db.prepare(`
|
||||
UPDATE users
|
||||
SET generation_count = COALESCE(generation_count, 0) + 1
|
||||
WHERE id = ?
|
||||
RETURNING generation_count
|
||||
`).get(userId) as { generation_count: number } | undefined;
|
||||
|
||||
return result?.generation_count || 1;
|
||||
}
|
||||
|
||||
export function canGenerate(userId: string, groups: string[]): { allowed: boolean; reason?: string; remaining?: number } {
|
||||
if (groups.includes('kaboot-ai-access')) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
const status = getSubscriptionStatus(userId);
|
||||
|
||||
if (status.status !== 'active') {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'No active subscription. Upgrade to access AI generation.',
|
||||
};
|
||||
}
|
||||
|
||||
if (status.generationsRemaining <= 0) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Generation limit reached for this billing period.',
|
||||
remaining: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: status.generationsRemaining,
|
||||
};
|
||||
}
|
||||
|
||||
export function recordPayment(
|
||||
userId: string,
|
||||
paymentIntentId: string | null,
|
||||
invoiceId: string | null,
|
||||
amount: number,
|
||||
currency: string,
|
||||
status: string,
|
||||
description: string
|
||||
): void {
|
||||
const id = `pay_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO payments (id, user_id, stripe_payment_intent_id, stripe_invoice_id, amount, currency, status, description)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, userId, paymentIntentId, invoiceId, amount, currency, status, description);
|
||||
}
|
||||
243
server/tests/payments.test.ts
Normal file
243
server/tests/payments.test.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import Database from 'better-sqlite3';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const API_URL = process.env.API_URL || 'http://localhost:3001';
|
||||
const TOKEN = process.env.TEST_TOKEN;
|
||||
|
||||
if (!TOKEN) {
|
||||
console.error('ERROR: TEST_TOKEN environment variable is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const results: TestResult[] = [];
|
||||
|
||||
async function request(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
expectStatus = 200,
|
||||
useToken = true
|
||||
): Promise<{ status: number; data: unknown; headers: Headers }> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (useToken) {
|
||||
headers['Authorization'] = `Bearer ${TOKEN}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const data = response.headers.get('content-type')?.includes('application/json')
|
||||
? await response.json()
|
||||
: null;
|
||||
|
||||
if (response.status !== expectStatus) {
|
||||
throw new Error(`Expected ${expectStatus}, got ${response.status}: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
return { status: response.status, data, headers: response.headers };
|
||||
}
|
||||
|
||||
async function test(name: string, fn: () => Promise<void>) {
|
||||
try {
|
||||
await fn();
|
||||
results.push({ name, passed: true });
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
results.push({ name, passed: false, error: message });
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
console.log('\n=== Kaboot Payments API Tests ===\n');
|
||||
console.log(`API: ${API_URL}`);
|
||||
console.log('');
|
||||
|
||||
console.log('Payment Config Tests:');
|
||||
|
||||
await test('GET /api/payments/config returns configuration', async () => {
|
||||
const res = await fetch(`${API_URL}/api/payments/config`);
|
||||
const data = await res.json();
|
||||
if (typeof data.configured !== 'boolean') {
|
||||
throw new Error('Missing configured field');
|
||||
}
|
||||
if (typeof data.generationLimit !== 'number') {
|
||||
throw new Error('Missing generationLimit field');
|
||||
}
|
||||
if (data.generationLimit !== 250) {
|
||||
throw new Error(`Expected generationLimit 250, got ${data.generationLimit}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\nPayment Status Tests:');
|
||||
|
||||
await test('GET /api/payments/status without auth returns 401', async () => {
|
||||
const res = await fetch(`${API_URL}/api/payments/status`);
|
||||
if (res.status !== 401) {
|
||||
throw new Error(`Expected 401, got ${res.status}`);
|
||||
}
|
||||
});
|
||||
|
||||
await test('GET /api/payments/status with invalid token returns 401', async () => {
|
||||
const res = await fetch(`${API_URL}/api/payments/status`, {
|
||||
headers: { Authorization: 'Bearer invalid-token-here' },
|
||||
});
|
||||
if (res.status !== 401) {
|
||||
throw new Error(`Expected 401, got ${res.status}`);
|
||||
}
|
||||
});
|
||||
|
||||
await test('GET /api/payments/status with valid token returns status', async () => {
|
||||
const { data } = await request('GET', '/api/payments/status');
|
||||
const status = data as Record<string, unknown>;
|
||||
|
||||
if (typeof status.hasAccess !== 'boolean') {
|
||||
throw new Error('Missing hasAccess field');
|
||||
}
|
||||
if (!['group', 'subscription', 'none'].includes(status.accessType as string)) {
|
||||
throw new Error(`Invalid accessType: ${status.accessType}`);
|
||||
}
|
||||
if (!['none', 'active', 'past_due', 'canceled'].includes(status.status as string)) {
|
||||
throw new Error(`Invalid status: ${status.status}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\nCheckout Tests:');
|
||||
|
||||
await test('POST /api/payments/checkout without auth returns 401', async () => {
|
||||
const res = await fetch(`${API_URL}/api/payments/checkout`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ planType: 'monthly', successUrl: 'http://test.com/success', cancelUrl: 'http://test.com/cancel' }),
|
||||
});
|
||||
if (res.status !== 401) {
|
||||
throw new Error(`Expected 401, got ${res.status}`);
|
||||
}
|
||||
});
|
||||
|
||||
await test('POST /api/payments/checkout without planType returns 400', async () => {
|
||||
const { status, data } = await request('POST', '/api/payments/checkout', {
|
||||
successUrl: 'http://test.com/success',
|
||||
cancelUrl: 'http://test.com/cancel',
|
||||
}, 400);
|
||||
const error = data as { error: string };
|
||||
if (!error.error.includes('plan')) {
|
||||
throw new Error(`Expected plan type error, got: ${error.error}`);
|
||||
}
|
||||
});
|
||||
|
||||
await test('POST /api/payments/checkout with invalid planType returns 400', async () => {
|
||||
const { data } = await request('POST', '/api/payments/checkout', {
|
||||
planType: 'invalid',
|
||||
successUrl: 'http://test.com/success',
|
||||
cancelUrl: 'http://test.com/cancel',
|
||||
}, 400);
|
||||
const error = data as { error: string };
|
||||
if (!error.error.includes('monthly') && !error.error.includes('yearly')) {
|
||||
throw new Error(`Expected plan type validation error, got: ${error.error}`);
|
||||
}
|
||||
});
|
||||
|
||||
await test('POST /api/payments/checkout without successUrl returns 400', async () => {
|
||||
const { data } = await request('POST', '/api/payments/checkout', {
|
||||
planType: 'monthly',
|
||||
cancelUrl: 'http://test.com/cancel',
|
||||
}, 400);
|
||||
const error = data as { error: string };
|
||||
if (!error.error.includes('successUrl')) {
|
||||
throw new Error(`Expected successUrl error, got: ${error.error}`);
|
||||
}
|
||||
});
|
||||
|
||||
await test('POST /api/payments/checkout without cancelUrl returns 400', async () => {
|
||||
const { data } = await request('POST', '/api/payments/checkout', {
|
||||
planType: 'monthly',
|
||||
successUrl: 'http://test.com/success',
|
||||
}, 400);
|
||||
const error = data as { error: string };
|
||||
if (!error.error.includes('cancelUrl')) {
|
||||
throw new Error(`Expected cancelUrl error, got: ${error.error}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\nPortal Tests:');
|
||||
|
||||
await test('POST /api/payments/portal without auth returns 401', async () => {
|
||||
const res = await fetch(`${API_URL}/api/payments/portal`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ returnUrl: 'http://test.com' }),
|
||||
});
|
||||
if (res.status !== 401) {
|
||||
throw new Error(`Expected 401, got ${res.status}`);
|
||||
}
|
||||
});
|
||||
|
||||
await test('POST /api/payments/portal without returnUrl returns 400', async () => {
|
||||
const { data } = await request('POST', '/api/payments/portal', {}, 400);
|
||||
const error = data as { error: string };
|
||||
if (!error.error.includes('returnUrl')) {
|
||||
throw new Error(`Expected returnUrl error, got: ${error.error}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\nWebhook Tests:');
|
||||
|
||||
await test('POST /api/payments/webhook without signature returns 400', async () => {
|
||||
const res = await fetch(`${API_URL}/api/payments/webhook`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'test' }),
|
||||
});
|
||||
if (res.status !== 400 && res.status !== 503) {
|
||||
throw new Error(`Expected 400 or 503, got ${res.status}`);
|
||||
}
|
||||
});
|
||||
|
||||
await test('POST /api/payments/webhook with invalid signature returns 400', async () => {
|
||||
const res = await fetch(`${API_URL}/api/payments/webhook`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'stripe-signature': 'invalid-signature',
|
||||
},
|
||||
body: JSON.stringify({ type: 'test' }),
|
||||
});
|
||||
if (res.status !== 400 && res.status !== 503) {
|
||||
throw new Error(`Expected 400 or 503, got ${res.status}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n=== Results ===\n');
|
||||
const passed = results.filter((r) => r.passed).length;
|
||||
const failed = results.filter((r) => !r.passed).length;
|
||||
console.log(`Passed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
console.log(`Total: ${results.length}`);
|
||||
|
||||
if (failed > 0) {
|
||||
console.log('\nFailed tests:');
|
||||
results
|
||||
.filter((r) => !r.passed)
|
||||
.forEach((r) => console.log(` - ${r.name}: ${r.error}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runTests().catch((err) => {
|
||||
console.error('Test runner error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue