Flesh out payment stuff
This commit is contained in:
parent
b0dcdd6438
commit
acfed861ab
27 changed files with 938 additions and 173 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue