Flesh out payment stuff

This commit is contained in:
Joey Yakimowich-Payne 2026-01-22 12:21:12 -07:00
commit acfed861ab
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
27 changed files with 938 additions and 173 deletions

View file

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