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:
Joey Yakimowich-Payne 2026-01-21 16:11:03 -07:00
commit 2e12edc249
No known key found for this signature in database
GPG key ID: DDF6AF5B21B407D4
22 changed files with 2866 additions and 21 deletions

View file

@ -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"
}

View file

@ -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": {

View file

@ -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();

View file

@ -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);

View file

@ -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);

View file

@ -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();
}

View file

@ -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);

View 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;

View 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);
}

View 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);
});