Flesh out payment stuff
This commit is contained in:
parent
b0dcdd6438
commit
acfed861ab
27 changed files with 938 additions and 173 deletions
|
|
@ -28,6 +28,7 @@ app.use(helmet({
|
|||
}));
|
||||
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
const isTest = process.env.NODE_ENV === 'test';
|
||||
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
|
|
@ -35,7 +36,7 @@ const apiLimiter = rateLimit({
|
|||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many requests, please try again later.' },
|
||||
skip: (req) => req.path === '/health',
|
||||
skip: (req) => isTest || req.path === '/health',
|
||||
});
|
||||
|
||||
const corsOrigins = (process.env.CORS_ORIGIN || 'http://localhost:5173').split(',').map(o => o.trim());
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ export function requireAIAccess(
|
|||
}
|
||||
|
||||
(req as any).aiAccessInfo = {
|
||||
accessType: groups.includes('kaboot-ai-access') ? 'group' : 'subscription',
|
||||
accessType: groups.includes('kaboot-ai-access') ? 'group' : (result.accessType || 'none'),
|
||||
remaining: result.remaining,
|
||||
} as AIAccessInfo;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@ import { randomBytes } from 'crypto';
|
|||
const router = Router();
|
||||
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
const isTest = process.env.NODE_ENV === 'test';
|
||||
const gameCreationLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: isDev ? 100 : 30,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many game creations, please try again later.' },
|
||||
skip: () => isTest,
|
||||
});
|
||||
|
||||
const gameLookupLimiter = rateLimit({
|
||||
|
|
@ -20,6 +22,7 @@ const gameLookupLimiter = rateLimit({
|
|||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many requests, please try again later.' },
|
||||
skip: () => isTest,
|
||||
});
|
||||
|
||||
const SESSION_TTL_MINUTES = parseInt(process.env.GAME_SESSION_TTL_MINUTES || '5', 10);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,49 @@
|
|||
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 { incrementGenerationCount, GENERATION_LIMIT, FREE_TIER_LIMIT } from '../services/stripe.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { buildQuizPrompt } from '../shared/quizPrompt.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
|
||||
const DEFAULT_MODEL = 'gemini-2.5-flash-preview-05-20';
|
||||
const DEFAULT_MODEL = 'gemini-3-flash-preview';
|
||||
const MAX_CONCURRENT_GENERATIONS = 2;
|
||||
|
||||
type GenerationJob<T> = {
|
||||
priority: number;
|
||||
run: () => Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
};
|
||||
|
||||
const generationQueue: GenerationJob<any>[] = [];
|
||||
let activeGenerations = 0;
|
||||
|
||||
const processGenerationQueue = () => {
|
||||
while (activeGenerations < MAX_CONCURRENT_GENERATIONS && generationQueue.length > 0) {
|
||||
const next = generationQueue.shift();
|
||||
if (!next) return;
|
||||
activeGenerations += 1;
|
||||
next
|
||||
.run()
|
||||
.then((result) => next.resolve(result))
|
||||
.catch((err) => next.reject(err))
|
||||
.finally(() => {
|
||||
activeGenerations -= 1;
|
||||
processGenerationQueue();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const enqueueGeneration = <T,>(priority: number, run: () => Promise<T>): Promise<T> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
generationQueue.push({ priority, run, resolve, reject });
|
||||
generationQueue.sort((a, b) => b.priority - a.priority);
|
||||
processGenerationQueue();
|
||||
});
|
||||
};
|
||||
|
||||
interface GenerateRequest {
|
||||
topic: string;
|
||||
|
|
@ -49,18 +85,8 @@ const QUIZ_SCHEMA = {
|
|||
required: ["title", "questions"]
|
||||
};
|
||||
|
||||
function buildPrompt(topic: string, questionCount: number, hasDocuments: boolean): string {
|
||||
const baseInstructions = `Create ${questionCount} engaging multiple-choice questions. Each question must have exactly 4 options, and exactly one correct answer. Vary the difficulty.
|
||||
|
||||
IMPORTANT: For each option's reason, write as if you are directly explaining facts - never reference "the document", "the text", "the material", or "the source". Write explanations as standalone factual statements.`;
|
||||
|
||||
if (hasDocuments) {
|
||||
const topicContext = topic ? ` Focus on aspects related to "${topic}".` : '';
|
||||
return `Generate a quiz based on the provided content.${topicContext}\n\n${baseInstructions}`;
|
||||
}
|
||||
|
||||
return `Generate a trivia quiz about "${topic}".\n\n${baseInstructions}`;
|
||||
}
|
||||
const buildPrompt = (topic: string, questionCount: number, hasDocuments: boolean): string =>
|
||||
buildQuizPrompt({ topic, questionCount, hasDocuments });
|
||||
|
||||
function shuffleArray<T>(array: T[]): T[] {
|
||||
const shuffled = [...array];
|
||||
|
|
@ -121,63 +147,79 @@ router.post('/', requireAuth, requireAIAccess, async (req: AuthenticatedRequest,
|
|||
}
|
||||
|
||||
try {
|
||||
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
|
||||
const hasDocuments = documents.length > 0;
|
||||
const prompt = buildPrompt(topic, questionCount, hasDocuments);
|
||||
|
||||
let contents: any;
|
||||
|
||||
if (hasDocuments) {
|
||||
const parts: any[] = [];
|
||||
|
||||
for (const doc of documents) {
|
||||
if (doc.type === 'native' && doc.mimeType) {
|
||||
const buffer = Buffer.from(doc.content, 'base64');
|
||||
const blob = new Blob([buffer], { type: doc.mimeType });
|
||||
|
||||
const uploadedFile = await ai.files.upload({
|
||||
file: blob,
|
||||
config: { mimeType: doc.mimeType }
|
||||
});
|
||||
|
||||
if (uploadedFile.uri && uploadedFile.mimeType) {
|
||||
parts.push(createPartFromUri(uploadedFile.uri, uploadedFile.mimeType));
|
||||
}
|
||||
} else if (doc.type === 'text') {
|
||||
parts.push({ text: doc.content });
|
||||
}
|
||||
}
|
||||
|
||||
parts.push({ text: prompt });
|
||||
contents = createUserContent(parts);
|
||||
} else {
|
||||
contents = prompt;
|
||||
}
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: DEFAULT_MODEL,
|
||||
contents,
|
||||
config: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: QUIZ_SCHEMA
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.text) {
|
||||
res.status(500).json({ error: 'Failed to generate quiz content' });
|
||||
const accessInfo = (req as any).aiAccessInfo as { accessType?: 'group' | 'subscription' | 'none'; remaining?: number } | undefined;
|
||||
if (accessInfo?.accessType === 'none' && documents.length > 1) {
|
||||
res.status(403).json({ error: 'Free plan allows a single document per generation.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.parse(response.text);
|
||||
const quiz = transformToQuiz(data);
|
||||
|
||||
const priority = accessInfo?.accessType === 'subscription' || accessInfo?.accessType === 'group' ? 1 : 0;
|
||||
const queuedAt = Date.now();
|
||||
|
||||
const quiz = await enqueueGeneration(priority, async () => {
|
||||
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
|
||||
const hasDocuments = documents.length > 0;
|
||||
const prompt = buildPrompt(topic, questionCount, hasDocuments);
|
||||
|
||||
let contents: any;
|
||||
|
||||
if (hasDocuments) {
|
||||
const parts: any[] = [];
|
||||
|
||||
for (const doc of documents) {
|
||||
if (doc.type === 'native' && doc.mimeType) {
|
||||
const buffer = Buffer.from(doc.content, 'base64');
|
||||
const blob = new Blob([buffer], { type: doc.mimeType });
|
||||
|
||||
const uploadedFile = await ai.files.upload({
|
||||
file: blob,
|
||||
config: { mimeType: doc.mimeType }
|
||||
});
|
||||
|
||||
if (uploadedFile.uri && uploadedFile.mimeType) {
|
||||
parts.push(createPartFromUri(uploadedFile.uri, uploadedFile.mimeType));
|
||||
}
|
||||
} else if (doc.type === 'text') {
|
||||
parts.push({ text: doc.content });
|
||||
}
|
||||
}
|
||||
|
||||
parts.push({ text: prompt });
|
||||
contents = createUserContent(parts);
|
||||
} else {
|
||||
contents = prompt;
|
||||
}
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: DEFAULT_MODEL,
|
||||
contents,
|
||||
config: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: QUIZ_SCHEMA
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.text) {
|
||||
throw new Error('Failed to generate quiz content');
|
||||
}
|
||||
|
||||
const data = JSON.parse(response.text);
|
||||
return transformToQuiz(data);
|
||||
});
|
||||
|
||||
const waitMs = Date.now() - queuedAt;
|
||||
if (waitMs > 0) {
|
||||
console.log('AI generation queued', { waitMs, priority });
|
||||
}
|
||||
|
||||
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);
|
||||
const limit = accessInfo?.accessType === 'subscription' ? GENERATION_LIMIT : FREE_TIER_LIMIT;
|
||||
const remaining = Math.max(0, limit - newCount);
|
||||
res.setHeader('X-Generations-Remaining', remaining.toString());
|
||||
}
|
||||
|
||||
|
||||
res.json(quiz);
|
||||
} catch (err: any) {
|
||||
console.error('AI generation error:', err);
|
||||
|
|
|
|||
|
|
@ -12,12 +12,22 @@ import {
|
|||
updateSubscriptionStatus,
|
||||
resetGenerationCount,
|
||||
recordPayment,
|
||||
updatePaymentStatus,
|
||||
GENERATION_LIMIT,
|
||||
} from '../services/stripe.js';
|
||||
|
||||
const router = Router();
|
||||
const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
|
||||
function requireAdmin(req: AuthenticatedRequest, res: Response): boolean {
|
||||
const groups = req.user?.groups || [];
|
||||
if (!groups.includes('kaboot-admin')) {
|
||||
res.status(403).json({ error: 'Admin access required' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
router.get('/config', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
configured: isStripeConfigured(),
|
||||
|
|
@ -92,15 +102,28 @@ router.post('/portal', requireAuth, async (req: AuthenticatedRequest, res: Respo
|
|||
}
|
||||
|
||||
const userId = req.user!.sub;
|
||||
const { returnUrl } = req.body;
|
||||
const { returnUrl, cancelSubscriptionId, afterCompletionUrl } = req.body;
|
||||
|
||||
if (!returnUrl) {
|
||||
res.status(400).json({ error: 'returnUrl is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (cancelSubscriptionId && typeof cancelSubscriptionId !== 'string') {
|
||||
res.status(400).json({ error: 'cancelSubscriptionId must be a string' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (afterCompletionUrl && typeof afterCompletionUrl !== 'string') {
|
||||
res.status(400).json({ error: 'afterCompletionUrl must be a string' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await createPortalSession(userId, returnUrl);
|
||||
const session = await createPortalSession(userId, returnUrl, {
|
||||
cancelSubscriptionId,
|
||||
afterCompletionUrl,
|
||||
});
|
||||
res.json({ url: session.url });
|
||||
} catch (err: any) {
|
||||
console.error('Portal session error:', err);
|
||||
|
|
@ -108,6 +131,49 @@ router.post('/portal', requireAuth, async (req: AuthenticatedRequest, res: Respo
|
|||
}
|
||||
});
|
||||
|
||||
router.post('/refund', requireAuth, async (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!isStripeConfigured()) {
|
||||
res.status(503).json({ error: 'Payments are not configured' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!requireAdmin(req, res)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { paymentIntentId, chargeId, amount, reason } = req.body as {
|
||||
paymentIntentId?: string;
|
||||
chargeId?: string;
|
||||
amount?: number;
|
||||
reason?: 'duplicate' | 'fraudulent' | 'requested_by_customer';
|
||||
};
|
||||
|
||||
if (!paymentIntentId && !chargeId) {
|
||||
res.status(400).json({ error: 'paymentIntentId or chargeId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (amount !== undefined && (typeof amount !== 'number' || amount <= 0)) {
|
||||
res.status(400).json({ error: 'amount must be a positive number if provided' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stripe = getStripe();
|
||||
const refund = await stripe.refunds.create({
|
||||
payment_intent: paymentIntentId,
|
||||
charge: chargeId,
|
||||
amount,
|
||||
reason,
|
||||
});
|
||||
|
||||
res.json({ refund });
|
||||
} catch (err: any) {
|
||||
console.error('Refund error:', err);
|
||||
res.status(500).json({ error: err.message || 'Failed to create refund' });
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
|
|
@ -228,6 +294,30 @@ async function handleInvoicePaymentFailed(invoice: Stripe.Invoice): Promise<void
|
|||
);
|
||||
}
|
||||
|
||||
async function handleRefundCreated(refund: Stripe.Refund): Promise<void> {
|
||||
const paymentIntentId = refund.payment_intent ? String(refund.payment_intent) : null;
|
||||
const updated = updatePaymentStatus(paymentIntentId, null, 'refunded', 'Refund created');
|
||||
|
||||
if (!updated) {
|
||||
console.warn('Refund created but no matching payment record found:', refund.id);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Recorded refund for payment intent:', paymentIntentId || refund.id);
|
||||
}
|
||||
|
||||
async function handleChargeRefunded(charge: Stripe.Charge): Promise<void> {
|
||||
const paymentIntentId = charge.payment_intent ? String(charge.payment_intent) : null;
|
||||
const updated = updatePaymentStatus(paymentIntentId, null, 'refunded', 'Charge refunded');
|
||||
|
||||
if (!updated) {
|
||||
console.warn('Charge refunded but no matching payment record found:', charge.id);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Recorded charge refund for payment intent:', paymentIntentId || charge.id);
|
||||
}
|
||||
|
||||
export const webhookHandler = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!STRIPE_WEBHOOK_SECRET) {
|
||||
res.status(503).json({ error: 'Webhook secret not configured' });
|
||||
|
|
@ -268,6 +358,12 @@ export const webhookHandler = async (req: Request, res: Response): Promise<void>
|
|||
case 'invoice.payment_failed':
|
||||
await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice);
|
||||
break;
|
||||
case 'refund.created':
|
||||
await handleRefundCreated(event.data.object as Stripe.Refund);
|
||||
break;
|
||||
case 'charge.refunded':
|
||||
await handleChargeRefunded(event.data.object as Stripe.Charge);
|
||||
break;
|
||||
default:
|
||||
console.log(`Unhandled event type: ${event.type}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ interface GameConfig {
|
|||
shuffleQuestions: boolean;
|
||||
shuffleAnswers: boolean;
|
||||
hostParticipates: boolean;
|
||||
randomNamesEnabled?: boolean;
|
||||
maxPlayers?: number;
|
||||
streakBonusEnabled: boolean;
|
||||
streakThreshold: number;
|
||||
streakMultiplier: number;
|
||||
|
|
@ -63,7 +65,8 @@ router.get('/', (req: AuthenticatedRequest, res: Response) => {
|
|||
|
||||
router.get('/:id', (req: AuthenticatedRequest, res: Response) => {
|
||||
const quiz = db.prepare(`
|
||||
SELECT id, title, source, ai_topic as aiTopic, game_config as gameConfig, created_at as createdAt, updated_at as updatedAt
|
||||
SELECT id, title, source, ai_topic as aiTopic, game_config as gameConfig, share_token as shareToken, is_shared as isShared,
|
||||
created_at as createdAt, updated_at as updatedAt
|
||||
FROM quizzes
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).get(req.params.id, req.user!.sub) as Record<string, unknown> | undefined;
|
||||
|
|
@ -108,6 +111,7 @@ router.get('/:id', (req: AuthenticatedRequest, res: Response) => {
|
|||
|
||||
res.json({
|
||||
...quiz,
|
||||
isShared: Boolean(quiz.isShared),
|
||||
gameConfig: parsedConfig,
|
||||
questions: questionsWithOptions,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import { processDocument, SUPPORTED_TYPES, normalizeMimeType } from '../services/documentParser.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { requireAuth, AuthenticatedRequest } from '../middleware/auth.js';
|
||||
import { getSubscriptionStatus } from '../services/stripe.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
|
|
@ -24,13 +25,23 @@ const upload = multer({
|
|||
}
|
||||
});
|
||||
|
||||
router.post('/', upload.single('document'), async (req, res) => {
|
||||
router.post('/', upload.single('document'), async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const useOcr = req.body?.useOcr === 'true' || req.body?.useOcr === true;
|
||||
if (useOcr) {
|
||||
const groups = req.user?.groups || [];
|
||||
const hasGroupAccess = groups.includes('kaboot-ai-access');
|
||||
const status = req.user ? getSubscriptionStatus(req.user.sub) : null;
|
||||
const hasSubscriptionAccess = status?.status === 'active';
|
||||
|
||||
if (!hasGroupAccess && !hasSubscriptionAccess) {
|
||||
return res.status(403).json({ error: 'OCR is available to Pro subscribers only.' });
|
||||
}
|
||||
}
|
||||
const normalizedMime = normalizeMimeType(req.file.mimetype, req.file.originalname);
|
||||
const processed = await processDocument(req.file.buffer, normalizedMime, { useOcr });
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
|
|||
|
||||
const groups = req.user!.groups || [];
|
||||
const hasAIAccess = groups.includes('kaboot-ai-access');
|
||||
const hasEarlyAccess = groups.includes('kaboot-early-access');
|
||||
|
||||
if (!row) {
|
||||
res.json({
|
||||
|
|
@ -54,6 +55,7 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
|
|||
defaultGameConfig: null,
|
||||
colorScheme: 'blue',
|
||||
hasAIAccess,
|
||||
hasEarlyAccess,
|
||||
createdAt: null,
|
||||
lastLogin: null,
|
||||
isNew: true,
|
||||
|
|
@ -69,6 +71,7 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
|
|||
email: req.user!.email,
|
||||
displayName: req.user!.name,
|
||||
hasAIAccess,
|
||||
hasEarlyAccess,
|
||||
isNew: false
|
||||
});
|
||||
});
|
||||
|
|
@ -116,6 +119,7 @@ router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
|
|||
|
||||
const groups = req.user!.groups || [];
|
||||
const hasAIAccess = groups.includes('kaboot-ai-access');
|
||||
const hasEarlyAccess = groups.includes('kaboot-early-access');
|
||||
|
||||
res.json({
|
||||
colorScheme: user?.colorScheme || 'blue',
|
||||
|
|
@ -127,12 +131,14 @@ router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
|
|||
openAIApiKey: null,
|
||||
openAIModel: user?.openAIModel || null,
|
||||
hasAIAccess,
|
||||
hasEarlyAccess,
|
||||
});
|
||||
});
|
||||
|
||||
router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
|
||||
const userSub = req.user!.sub;
|
||||
const { colorScheme, geminiModel, aiProvider, openRouterModel, openAIModel } = req.body;
|
||||
const requestedProvider = aiProvider || 'gemini';
|
||||
|
||||
const upsertUser = db.prepare(`
|
||||
INSERT INTO users (id, color_scheme, gemini_model, ai_provider, openrouter_model, openai_model, last_login)
|
||||
|
|
@ -150,12 +156,12 @@ router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
|
|||
userSub,
|
||||
colorScheme || 'blue',
|
||||
geminiModel || null,
|
||||
aiProvider || 'gemini',
|
||||
requestedProvider,
|
||||
openRouterModel || null,
|
||||
openAIModel || null,
|
||||
colorScheme || 'blue',
|
||||
geminiModel || null,
|
||||
aiProvider || 'gemini',
|
||||
requestedProvider,
|
||||
openRouterModel || null,
|
||||
openAIModel || null
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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;
|
||||
export const FREE_TIER_LIMIT = 5;
|
||||
|
||||
let stripeClient: Stripe | null = null;
|
||||
|
||||
|
|
@ -90,20 +91,58 @@ export async function createCheckoutSession(
|
|||
|
||||
export async function createPortalSession(
|
||||
userId: string,
|
||||
returnUrl: string
|
||||
returnUrl: string,
|
||||
options?: {
|
||||
cancelSubscriptionId?: string;
|
||||
afterCompletionUrl?: 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;
|
||||
const user = db.prepare('SELECT stripe_customer_id, subscription_id FROM users WHERE id = ?').get(userId) as {
|
||||
stripe_customer_id: string | null;
|
||||
subscription_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({
|
||||
|
||||
if (options?.cancelSubscriptionId) {
|
||||
if (!user.subscription_id) {
|
||||
throw new Error('No active subscription found for this user');
|
||||
}
|
||||
|
||||
if (user.subscription_id !== options.cancelSubscriptionId) {
|
||||
throw new Error('Subscription mismatch for cancellation request');
|
||||
}
|
||||
}
|
||||
|
||||
const portalParams: Stripe.BillingPortal.SessionCreateParams = {
|
||||
customer: user.stripe_customer_id,
|
||||
return_url: returnUrl,
|
||||
});
|
||||
};
|
||||
|
||||
if (options?.cancelSubscriptionId) {
|
||||
portalParams.flow_data = {
|
||||
type: 'subscription_cancel',
|
||||
subscription_cancel: {
|
||||
subscription: options.cancelSubscriptionId,
|
||||
},
|
||||
...(options.afterCompletionUrl
|
||||
? {
|
||||
after_completion: {
|
||||
type: 'redirect',
|
||||
redirect: {
|
||||
return_url: options.afterCompletionUrl,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
const session = await stripe.billingPortal.sessions.create(portalParams);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
|
@ -116,6 +155,25 @@ export interface SubscriptionStatus {
|
|||
generationsRemaining: number;
|
||||
}
|
||||
|
||||
function nextFreeTierResetDate(now: Date): Date {
|
||||
const next = new Date(now);
|
||||
next.setMonth(next.getMonth() + 1);
|
||||
return next;
|
||||
}
|
||||
|
||||
function ensureFreeTierReset(userId: string, resetDate: string | null): string {
|
||||
const now = new Date();
|
||||
const currentReset = resetDate ? new Date(resetDate) : null;
|
||||
|
||||
if (!currentReset || Number.isNaN(currentReset.getTime()) || currentReset <= now) {
|
||||
const nextReset = nextFreeTierResetDate(now);
|
||||
resetGenerationCount(userId, nextReset);
|
||||
return nextReset.toISOString();
|
||||
}
|
||||
|
||||
return currentReset.toISOString();
|
||||
}
|
||||
|
||||
export function getSubscriptionStatus(userId: string): SubscriptionStatus {
|
||||
const user = db.prepare(`
|
||||
SELECT subscription_status, subscription_current_period_end, generation_count, generation_reset_date
|
||||
|
|
@ -129,13 +187,20 @@ export function getSubscriptionStatus(userId: string): SubscriptionStatus {
|
|||
|
||||
const status = (user?.subscription_status || 'none') as SubscriptionStatus['status'];
|
||||
const generationCount = user?.generation_count || 0;
|
||||
const isSubscriptionActive = status === 'active';
|
||||
|
||||
const effectiveResetDate = !isSubscriptionActive
|
||||
? ensureFreeTierReset(userId, user?.generation_reset_date || null)
|
||||
: user?.subscription_current_period_end || null;
|
||||
|
||||
const generationLimit = isSubscriptionActive ? GENERATION_LIMIT : FREE_TIER_LIMIT;
|
||||
|
||||
return {
|
||||
status,
|
||||
currentPeriodEnd: user?.subscription_current_period_end || null,
|
||||
currentPeriodEnd: isSubscriptionActive ? (user?.subscription_current_period_end || null) : effectiveResetDate,
|
||||
generationCount,
|
||||
generationLimit: GENERATION_LIMIT,
|
||||
generationsRemaining: Math.max(0, GENERATION_LIMIT - generationCount),
|
||||
generationLimit,
|
||||
generationsRemaining: Math.max(0, generationLimit - generationCount),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -196,31 +261,27 @@ export function incrementGenerationCount(userId: string): number {
|
|||
return result?.generation_count || 1;
|
||||
}
|
||||
|
||||
export function canGenerate(userId: string, groups: string[]): { allowed: boolean; reason?: string; remaining?: number } {
|
||||
export function canGenerate(userId: string, groups: string[]): { allowed: boolean; reason?: string; remaining?: number; accessType?: 'group' | 'subscription' | 'none' } {
|
||||
if (groups.includes('kaboot-ai-access')) {
|
||||
return { allowed: true };
|
||||
return { allowed: true, accessType: 'group' };
|
||||
}
|
||||
|
||||
const status = getSubscriptionStatus(userId);
|
||||
|
||||
if (status.status !== 'active') {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'No active subscription. Upgrade to access AI generation.',
|
||||
};
|
||||
}
|
||||
|
||||
const accessType: 'subscription' | 'none' = status.status === 'active' ? 'subscription' : 'none';
|
||||
|
||||
if (status.generationsRemaining <= 0) {
|
||||
return {
|
||||
allowed: false,
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Generation limit reached for this billing period.',
|
||||
remaining: 0,
|
||||
accessType,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: status.generationsRemaining,
|
||||
accessType,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -240,3 +301,34 @@ export function recordPayment(
|
|||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, userId, paymentIntentId, invoiceId, amount, currency, status, description);
|
||||
}
|
||||
|
||||
export function updatePaymentStatus(
|
||||
paymentIntentId: string | null,
|
||||
invoiceId: string | null,
|
||||
status: string,
|
||||
description?: string
|
||||
): boolean {
|
||||
const updateByPaymentIntent = paymentIntentId
|
||||
? db.prepare(`
|
||||
UPDATE payments
|
||||
SET status = ?, description = COALESCE(?, description)
|
||||
WHERE stripe_payment_intent_id = ?
|
||||
`).run(status, description ?? null, paymentIntentId)
|
||||
: null;
|
||||
|
||||
if (updateByPaymentIntent && updateByPaymentIntent.changes > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!invoiceId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const updateByInvoice = db.prepare(`
|
||||
UPDATE payments
|
||||
SET status = ?, description = COALESCE(?, description)
|
||||
WHERE stripe_invoice_id = ?
|
||||
`).run(status, description ?? null, invoiceId);
|
||||
|
||||
return updateByInvoice.changes > 0;
|
||||
}
|
||||
|
|
|
|||
58
server/src/shared/quizPrompt.ts
Normal file
58
server/src/shared/quizPrompt.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
export interface QuizPromptOptions {
|
||||
topic?: string;
|
||||
questionCount: number;
|
||||
hasDocuments: boolean;
|
||||
includeJsonExample?: boolean;
|
||||
}
|
||||
|
||||
export const TITLE_GUIDANCE = `Title guidance: Make the title fun and varied. Use playful phrasing, light wordplay, or energetic wording. Avoid templates like "The Ultimate ... Quiz" or "Test your knowledge". Keep it short (2-6 words) and specific to the topic, with clear reference to the original topic.`;
|
||||
|
||||
export const EXPLANATION_GUIDANCE = `IMPORTANT: For each option's reason, write as if you are directly explaining facts - never reference "the document", "the text", "the material", or "the source". Write explanations as standalone factual statements.`;
|
||||
|
||||
export const JSON_EXAMPLE_GUIDANCE = `You MUST respond with a single JSON object in this exact structure:
|
||||
{
|
||||
"title": "Quiz Title Here",
|
||||
"questions": [
|
||||
{
|
||||
"text": "Question text here?",
|
||||
"options": [
|
||||
{ "text": "Option A", "isCorrect": false, "reason": "Explanation why this is wrong" },
|
||||
{ "text": "Option B", "isCorrect": true, "reason": "Explanation why this is correct" },
|
||||
{ "text": "Option C", "isCorrect": false, "reason": "Explanation why this is wrong" },
|
||||
{ "text": "Option D", "isCorrect": false, "reason": "Explanation why this is wrong" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
There can be 2-6 options. Use 2 for true/false style questions.
|
||||
|
||||
Return ONLY valid JSON with no additional text before or after.`;
|
||||
|
||||
export function buildQuizPrompt(options: QuizPromptOptions): string {
|
||||
const questionCount = options.questionCount || 10;
|
||||
let baseInstructions = `Create ${questionCount} engaging multiple-choice questions. Each question must have 2-6 options, and exactly one correct answer. Vary the difficulty.
|
||||
|
||||
${TITLE_GUIDANCE}
|
||||
|
||||
${EXPLANATION_GUIDANCE}`;
|
||||
|
||||
if (options.includeJsonExample) {
|
||||
baseInstructions += `
|
||||
|
||||
${JSON_EXAMPLE_GUIDANCE}`;
|
||||
}
|
||||
|
||||
if (options.hasDocuments) {
|
||||
const topicContext = options.topic
|
||||
? ` Focus on aspects related to "${options.topic}".`
|
||||
: '';
|
||||
return `Generate a quiz based on the provided content.${topicContext}
|
||||
|
||||
${baseInstructions}`;
|
||||
}
|
||||
|
||||
return `Generate a trivia quiz about "${options.topic}".
|
||||
|
||||
${baseInstructions}`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue