From f1d02720d0d5e1d3f08e6e84812e9b0fe90c1379 Mon Sep 17 00:00:00 2001 From: javayhu Date: Sun, 17 Aug 2025 23:26:10 +0800 Subject: [PATCH] refactor: restructure credit distribution logic and enhance user credit handling --- src/app/api/distribute-credits/route.ts | 12 +- src/credits/credits.ts | 557 ++++------------------ src/credits/distribute.ts | 604 ++++++++++++++++++++++++ src/lib/auth.ts | 30 +- src/payment/provider/stripe.ts | 43 +- 5 files changed, 736 insertions(+), 510 deletions(-) create mode 100644 src/credits/distribute.ts diff --git a/src/app/api/distribute-credits/route.ts b/src/app/api/distribute-credits/route.ts index 244a316..6f3c5c0 100644 --- a/src/app/api/distribute-credits/route.ts +++ b/src/app/api/distribute-credits/route.ts @@ -1,4 +1,4 @@ -import { distributeCreditsToAllUsers } from '@/credits/credits'; +import { distributeCreditsToAllUsers } from '@/credits/distribute'; import { NextResponse } from 'next/server'; // Basic authentication middleware @@ -45,13 +45,15 @@ export async function GET(request: Request) { }); } - console.log('distribute credits start'); - const { processedCount, errorCount } = await distributeCreditsToAllUsers(); + console.log('route: distribute credits start'); + const { usersCount, processedCount, errorCount } = + await distributeCreditsToAllUsers(); console.log( - `distribute credits end, processed: ${processedCount}, errors: ${errorCount}` + `route: distribute credits end, users: ${usersCount}, processed: ${processedCount}, errors: ${errorCount}` ); return NextResponse.json({ - message: `distribute credits success, processed: ${processedCount}, errors: ${errorCount}`, + message: `distribute credits success, users: ${usersCount}, processed: ${processedCount}, errors: ${errorCount}`, + usersCount, processedCount, errorCount, }); diff --git a/src/credits/credits.ts b/src/credits/credits.ts index 05ce229..7992c6a 100644 --- a/src/credits/credits.ts +++ b/src/credits/credits.ts @@ -1,11 +1,10 @@ import { randomUUID } from 'crypto'; import { websiteConfig } from '@/config/website'; import { getDb } from '@/db'; -import { creditTransaction, payment, user, userCredit } from '@/db/schema'; +import { creditTransaction, userCredit } from '@/db/schema'; import { findPlanByPriceId } from '@/lib/price-plan'; import { addDays, isAfter } from 'date-fns'; -import { and, asc, desc, eq, gt, inArray, isNull, not, or } from 'drizzle-orm'; -import { sql } from 'drizzle-orm'; +import { and, asc, eq, gt, isNull, not, or } from 'drizzle-orm'; import { CREDIT_TRANSACTION_TYPE } from './types'; /** @@ -176,6 +175,11 @@ export async function addCredits({ }); } +/** + * Check if user has enough credits + * @param userId - User ID + * @param requiredCredits - Required credits + */ export async function hasEnoughCredits({ userId, requiredCredits, @@ -355,15 +359,40 @@ export async function processExpiredCredits(userId: string) { } } +/** + * Check if credits can be added for a user based on last refresh time + * @param userId - User ID + */ +export async function canAddMonthlyCredits(userId: string) { + const db = await getDb(); + const record = await db + .select() + .from(userCredit) + .where(eq(userCredit.userId, userId)) + .limit(1); + + const now = new Date(); + let canAdd = false; + + // Check if user has never received credits or it's a new month + if (!record[0]?.lastRefreshAt) { + canAdd = true; + } else { + // different month or year means new month + const last = new Date(record[0].lastRefreshAt); + canAdd = + now.getMonth() !== last.getMonth() || + now.getFullYear() !== last.getFullYear(); + } + + return canAdd; +} + /** * Add register gift credits * @param userId - User ID */ export async function addRegisterGiftCredits(userId: string) { - if (!websiteConfig.credits.registerGiftCredits.enable) { - console.log('addRegisterGiftCredits, disabled'); - return; - } // Check if user has already received register gift credits const db = await getDb(); const record = await db @@ -376,6 +405,7 @@ export async function addRegisterGiftCredits(userId: string) { ) ) .limit(1); + // add register gift credits if user has not received them yet if (record.length === 0) { const credits = websiteConfig.credits.registerGiftCredits.credits; @@ -397,49 +427,31 @@ export async function addRegisterGiftCredits(userId: string) { /** * Add free monthly credits * @param userId - User ID + * @param priceId - Price ID */ -export async function addMonthlyFreeCredits(userId: string) { - const freePlan = Object.values(websiteConfig.price.plans).find( - (plan) => plan.isFree && !plan.disabled - ); - if (!freePlan) { - console.log('addMonthlyFreeCredits, no free plan found'); - return; - } +export async function addMonthlyFreeCredits(userId: string, priceId: string) { + // NOTICE: make sure the free plan is not disabled and has credits enabled + const pricePlan = findPlanByPriceId(priceId); if ( - freePlan.disabled || - !freePlan.credits?.enable || - !freePlan.credits?.amount + !pricePlan || + pricePlan.disabled || + !pricePlan.isFree || + !pricePlan.credits || + !pricePlan.credits.enable ) { console.log( - 'addMonthlyFreeCredits, plan disabled or credits disabled', - freePlan.id + `addMonthlyFreeCredits, no credits configured for plan ${priceId}` ); return; } - // Check last refresh time - const db = await getDb(); - const record = await db - .select() - .from(userCredit) - .where(eq(userCredit.userId, userId)) - .limit(1); + + const canAdd = await canAddMonthlyCredits(userId); const now = new Date(); - let canAdd = false; - // never added credits before - if (!record[0]?.lastRefreshAt) { - canAdd = true; - } else { - const last = new Date(record[0].lastRefreshAt); - // different month or year means new month - canAdd = - now.getMonth() !== last.getMonth() || - now.getFullYear() !== last.getFullYear(); - } + // add credits if it's a new month if (canAdd) { - const credits = freePlan.credits.amount; - const expireDays = freePlan.credits.expireDays; + const credits = pricePlan.credits?.amount || 0; + const expireDays = pricePlan.credits?.expireDays || 0; await addCredits({ userId, amount: credits, @@ -454,92 +466,93 @@ export async function addMonthlyFreeCredits(userId: string) { console.log( `addMonthlyFreeCredits, ${credits} credits for user ${userId}, date: ${now.getFullYear()}-${now.getMonth() + 1}` ); + } else { + console.log( + `addMonthlyFreeCredits, no new month for user ${userId}, date: ${now.getFullYear()}-${now.getMonth() + 1}` + ); } } /** - * Add subscription renewal credits + * Add subscription credits * @param userId - User ID * @param priceId - Price ID */ export async function addSubscriptionCredits(userId: string, priceId: string) { + // NOTICE: the price plan maybe disabled, but we still need to add credits for existing users const pricePlan = findPlanByPriceId(priceId); if ( !pricePlan || - pricePlan.isFree || + // pricePlan.disabled || !pricePlan.credits || !pricePlan.credits.enable ) { console.log( - `addSubscriptionRenewalCredits, no credits configured for plan ${priceId}` + `addSubscriptionCredits, no credits configured for plan ${priceId}` ); return; } - const credits = pricePlan.credits.amount; - const expireDays = pricePlan.credits.expireDays; + const canAdd = await canAddMonthlyCredits(userId); const now = new Date(); - await addCredits({ - userId, - amount: credits, - type: CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL, - description: `Subscription renewal credits: ${credits} for ${now.getFullYear()}-${now.getMonth() + 1}`, - expireDays, - }); + // Add credits if it's a new month + if (canAdd) { + const credits = pricePlan.credits.amount; + const expireDays = pricePlan.credits.expireDays; - console.log( - `addSubscriptionRenewalCredits, ${credits} credits for user ${userId}, priceId: ${priceId}` - ); + await addCredits({ + userId, + amount: credits, + type: CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL, + description: `Subscription renewal credits: ${credits} for ${now.getFullYear()}-${now.getMonth() + 1}`, + expireDays, + }); + + // Update last refresh time for subscription credits + await updateUserLastRefreshAt(userId, now); + + console.log( + `addSubscriptionCredits, ${credits} credits for user ${userId}, priceId: ${priceId}, date: ${now.getFullYear()}-${now.getMonth() + 1}` + ); + } else { + console.log( + `addSubscriptionCredits, no new month for user ${userId}, date: ${now.getFullYear()}-${now.getMonth() + 1}` + ); + } } /** * Add lifetime monthly credits * @param userId - User ID + * @param priceId - Price ID */ -export async function addLifetimeMonthlyCredits(userId: string) { - const lifetimePlan = Object.values(websiteConfig.price.plans).find( - (plan) => plan.isLifetime && !plan.disabled - ); +export async function addLifetimeMonthlyCredits( + userId: string, + priceId: string +) { + // NOTICE: make sure the lifetime plan is not disabled and has credits enabled + const pricePlan = findPlanByPriceId(priceId); if ( - !lifetimePlan || - lifetimePlan.disabled || - !lifetimePlan.credits || - !lifetimePlan.credits.enable + !pricePlan || + !pricePlan.isLifetime || + pricePlan.disabled || + !pricePlan.credits || + !pricePlan.credits.enable ) { console.log( - 'addLifetimeMonthlyCredits, plan disabled or credits disabled', - lifetimePlan?.id + `addLifetimeMonthlyCredits, no credits configured for plan ${priceId}` ); return; } - // Check last refresh time to avoid duplicate monthly credits - const db = await getDb(); - const record = await db - .select() - .from(userCredit) - .where(eq(userCredit.userId, userId)) - .limit(1); - + const canAdd = await canAddMonthlyCredits(userId); const now = new Date(); - let canAdd = false; - - // Check if user has never received lifetime credits or it's a new month - if (!record[0]?.lastRefreshAt) { - canAdd = true; - } else { - const last = new Date(record[0].lastRefreshAt); - // different month or year means new month - canAdd = - now.getMonth() !== last.getMonth() || - now.getFullYear() !== last.getFullYear(); - } // Add credits if it's a new month if (canAdd) { - const credits = lifetimePlan.credits.amount; - const expireDays = lifetimePlan.credits.expireDays; + const credits = pricePlan.credits.amount; + const expireDays = pricePlan.credits.expireDays; await addCredits({ userId, @@ -555,375 +568,9 @@ export async function addLifetimeMonthlyCredits(userId: string) { console.log( `addLifetimeMonthlyCredits, ${credits} credits for user ${userId}, date: ${now.getFullYear()}-${now.getMonth() + 1}` ); - } -} - -/** - * Distribute credits to all users based on their plan type - * This function is designed to be called by a cron job - */ -export async function distributeCreditsToAllUsers() { - console.log('distribute credits start'); - - const db = await getDb(); - - // Get all users with their current active payments/subscriptions in a single query - // This uses a LEFT JOIN to get users and their latest active payment in one query - const latestPaymentQuery = db - .select({ - userId: payment.userId, - priceId: payment.priceId, - status: payment.status, - createdAt: payment.createdAt, - rowNumber: - sql`ROW_NUMBER() OVER (PARTITION BY ${payment.userId} ORDER BY ${payment.createdAt} DESC)`.as( - 'row_number' - ), - }) - .from(payment) - .where(or(eq(payment.status, 'active'), eq(payment.status, 'trialing'))) - .as('latest_payment'); - - const usersWithPayments = await db - .select({ - userId: user.id, - email: user.email, - name: user.name, - priceId: latestPaymentQuery.priceId, - paymentStatus: latestPaymentQuery.status, - paymentCreatedAt: latestPaymentQuery.createdAt, - }) - .from(user) - .leftJoin( - latestPaymentQuery, - and( - eq(user.id, latestPaymentQuery.userId), - eq(latestPaymentQuery.rowNumber, 1) - ) - ) - .where(or(isNull(user.banned), eq(user.banned, false))); - - console.log('distribute credits, users count:', usersWithPayments.length); - - let processedCount = 0; - let errorCount = 0; - - // Separate users by their plan type for batch processing - const lifetimeUserIds: string[] = []; - const freeUserIds: string[] = []; - - usersWithPayments.forEach((userRecord) => { - if (userRecord.priceId && userRecord.paymentStatus) { - // User has active subscription - check what type - const pricePlan = findPlanByPriceId(userRecord.priceId); - if (pricePlan?.isLifetime) { - lifetimeUserIds.push(userRecord.userId); - } - // Note: Subscription renewals are handled by Stripe webhooks, not here - } else { - // User has no active subscription - add free monthly credits if enabled - freeUserIds.push(userRecord.userId); - } - }); - - console.log( - `distribute credits, lifetime users: ${lifetimeUserIds.length}, free users: ${freeUserIds.length}` - ); - - // Process lifetime users in batches - const batchSize = 100; - for (let i = 0; i < lifetimeUserIds.length; i += batchSize) { - const batch = lifetimeUserIds.slice(i, i + batchSize); - try { - await batchAddLifetimeMonthlyCredits(batch); - processedCount += batch.length; - } catch (error) { - console.error( - `batchAddLifetimeMonthlyCredits error for batch ${i / batchSize + 1}:`, - error - ); - errorCount += batch.length; - } - - // Log progress for large datasets - if (lifetimeUserIds.length > 1000) { - console.log( - `lifetime credits progress: ${Math.min(i + batchSize, lifetimeUserIds.length)}/${lifetimeUserIds.length}` - ); - } - } - - // Process free users in batches - for (let i = 0; i < freeUserIds.length; i += batchSize) { - const batch = freeUserIds.slice(i, i + batchSize); - try { - await batchAddMonthlyFreeCredits(batch); - processedCount += batch.length; - } catch (error) { - console.error( - `batchAddMonthlyFreeCredits error for batch ${i / batchSize + 1}:`, - error - ); - errorCount += batch.length; - } - - // Log progress for large datasets - if (freeUserIds.length > 1000) { - console.log( - `free credits progress: ${Math.min(i + batchSize, freeUserIds.length)}/${freeUserIds.length}` - ); - } - } - - console.log( - `distribute credits end, processed: ${processedCount}, errors: ${errorCount}` - ); - return { processedCount, errorCount }; -} - -/** - * Batch add monthly free credits to multiple users - * @param userIds - Array of user IDs - */ -export async function batchAddMonthlyFreeCredits(userIds: string[]) { - if (userIds.length === 0) return; - - const freePlan = Object.values(websiteConfig.price.plans).find( - (plan) => plan.isFree && !plan.disabled - ); - if (!freePlan) { - console.log('batchAddMonthlyFreeCredits, no free plan found'); - return; - } - if ( - freePlan.disabled || - !freePlan.credits?.enable || - !freePlan.credits?.amount - ) { + } else { console.log( - 'batchAddMonthlyFreeCredits, plan disabled or credits disabled', - freePlan.id + `addLifetimeMonthlyCredits, no new month for user ${userId}, date: ${now.getFullYear()}-${now.getMonth() + 1}` ); - return; } - - const db = await getDb(); - const now = new Date(); - const credits = freePlan.credits.amount; - const expireDays = freePlan.credits.expireDays; - - // Use transaction for data consistency - let processedCount = 0; - await db.transaction(async (tx) => { - // Get all user credit records in one query - const userCredits = await tx - .select({ - userId: userCredit.userId, - lastRefreshAt: userCredit.lastRefreshAt, - currentCredits: userCredit.currentCredits, - }) - .from(userCredit) - .where(inArray(userCredit.userId, userIds)); - - // Create a map for quick lookup - const userCreditMap = new Map( - userCredits.map((record) => [record.userId, record]) - ); - - // Filter users who can receive credits - const eligibleUserIds = userIds.filter((userId) => { - const record = userCreditMap.get(userId); - if (!record?.lastRefreshAt) { - return true; // never added credits before - } - const last = new Date(record.lastRefreshAt); - // different month or year means new month - return ( - now.getMonth() !== last.getMonth() || - now.getFullYear() !== last.getFullYear() - ); - }); - - if (eligibleUserIds.length === 0) { - console.log('batchAddMonthlyFreeCredits, no eligible users'); - return; - } - - processedCount = eligibleUserIds.length; - const expirationDate = expireDays ? addDays(now, expireDays) : undefined; - - // Batch insert credit transactions - const transactions = eligibleUserIds.map((userId) => ({ - id: randomUUID(), - userId, - type: CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH, - amount: credits, - remainingAmount: credits, - description: `Free monthly credits: ${credits} for ${now.getFullYear()}-${now.getMonth() + 1}`, - expirationDate, - createdAt: now, - updatedAt: now, - })); - - await tx.insert(creditTransaction).values(transactions); - - // Prepare user credit updates - const existingUserIds = eligibleUserIds.filter((userId) => - userCreditMap.has(userId) - ); - const newUserIds = eligibleUserIds.filter( - (userId) => !userCreditMap.has(userId) - ); - - // Insert new user credit records - if (newUserIds.length > 0) { - const newRecords = newUserIds.map((userId) => ({ - id: randomUUID(), - userId, - currentCredits: credits, - lastRefreshAt: now, - createdAt: now, - updatedAt: now, - })); - await tx.insert(userCredit).values(newRecords); - } - - // Update existing user credit records - if (existingUserIds.length > 0) { - await tx - .update(userCredit) - .set({ - currentCredits: credits, - lastRefreshAt: now, - updatedAt: now, - }) - .where(inArray(userCredit.userId, existingUserIds)); - } - }); - - console.log( - `batchAddMonthlyFreeCredits, ${credits} credits for ${processedCount} users, date: ${now.getFullYear()}-${now.getMonth() + 1}` - ); -} - -/** - * Batch add lifetime monthly credits to multiple users - * @param userIds - Array of user IDs - */ -export async function batchAddLifetimeMonthlyCredits(userIds: string[]) { - if (userIds.length === 0) return; - - const lifetimePlan = Object.values(websiteConfig.price.plans).find( - (plan) => plan.isLifetime && !plan.disabled - ); - if ( - !lifetimePlan || - lifetimePlan.disabled || - !lifetimePlan.credits || - !lifetimePlan.credits.enable - ) { - console.log( - 'batchAddLifetimeMonthlyCredits, plan disabled or credits disabled', - lifetimePlan?.id - ); - return; - } - - const db = await getDb(); - const now = new Date(); - const credits = lifetimePlan.credits.amount; - const expireDays = lifetimePlan.credits.expireDays; - - // Use transaction for data consistency - let processedCount = 0; - await db.transaction(async (tx) => { - // Get all user credit records in one query - const userCredits = await tx - .select({ - userId: userCredit.userId, - lastRefreshAt: userCredit.lastRefreshAt, - currentCredits: userCredit.currentCredits, - }) - .from(userCredit) - .where(inArray(userCredit.userId, userIds)); - - // Create a map for quick lookup - const userCreditMap = new Map( - userCredits.map((record) => [record.userId, record]) - ); - - // Filter users who can receive credits - const eligibleUserIds = userIds.filter((userId) => { - const record = userCreditMap.get(userId); - if (!record?.lastRefreshAt) { - return true; // never added credits before - } - const last = new Date(record.lastRefreshAt); - // different month or year means new month - return ( - now.getMonth() !== last.getMonth() || - now.getFullYear() !== last.getFullYear() - ); - }); - - if (eligibleUserIds.length === 0) { - console.log('batchAddLifetimeMonthlyCredits, no eligible users'); - return; - } - - processedCount = eligibleUserIds.length; - const expirationDate = expireDays ? addDays(now, expireDays) : undefined; - - // Batch insert credit transactions - const transactions = eligibleUserIds.map((userId) => ({ - id: randomUUID(), - userId, - type: CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY, - amount: credits, - remainingAmount: credits, - description: `Lifetime monthly credits: ${credits} for ${now.getFullYear()}-${now.getMonth() + 1}`, - expirationDate, - createdAt: now, - updatedAt: now, - })); - - await tx.insert(creditTransaction).values(transactions); - - // Prepare user credit updates - const existingUserIds = eligibleUserIds.filter((userId) => - userCreditMap.has(userId) - ); - const newUserIds = eligibleUserIds.filter( - (userId) => !userCreditMap.has(userId) - ); - - // Insert new user credit records - if (newUserIds.length > 0) { - const newRecords = newUserIds.map((userId) => ({ - id: randomUUID(), - userId, - currentCredits: credits, - lastRefreshAt: now, - createdAt: now, - updatedAt: now, - })); - await tx.insert(userCredit).values(newRecords); - } - - // Update existing user credit records - if (existingUserIds.length > 0) { - await tx - .update(userCredit) - .set({ - currentCredits: credits, - lastRefreshAt: now, - updatedAt: now, - }) - .where(inArray(userCredit.userId, existingUserIds)); - } - }); - - console.log( - `batchAddLifetimeMonthlyCredits, ${credits} credits for ${processedCount} users, date: ${now.getFullYear()}-${now.getMonth() + 1}` - ); } diff --git a/src/credits/distribute.ts b/src/credits/distribute.ts new file mode 100644 index 0000000..4465e40 --- /dev/null +++ b/src/credits/distribute.ts @@ -0,0 +1,604 @@ +import { randomUUID } from 'crypto'; +import { getDb } from '@/db'; +import { creditTransaction, payment, user, userCredit } from '@/db/schema'; +import { findPlanByPriceId, getAllPricePlans } from '@/lib/price-plan'; +import { PlanIntervals } from '@/payment/types'; +import { addDays } from 'date-fns'; +import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm'; +import { CREDIT_TRANSACTION_TYPE } from './types'; + +/** + * Distribute credits to all users based on their plan type + * This function is designed to be called by a cron job + */ +export async function distributeCreditsToAllUsers() { + console.log('>>> distribute credits start'); + + const db = await getDb(); + + // Get all users with their current active payments/subscriptions in a single query + // This uses a LEFT JOIN to get users and their latest active payment in one query + const latestPaymentQuery = db + .select({ + userId: payment.userId, + priceId: payment.priceId, + status: payment.status, + createdAt: payment.createdAt, + rowNumber: + sql`ROW_NUMBER() OVER (PARTITION BY ${payment.userId} ORDER BY ${payment.createdAt} DESC)`.as( + 'row_number' + ), + }) + .from(payment) + .where(or(eq(payment.status, 'active'), eq(payment.status, 'trialing'))) + .as('latest_payment'); + + const usersWithPayments = await db + .select({ + userId: user.id, + email: user.email, + name: user.name, + priceId: latestPaymentQuery.priceId, + paymentStatus: latestPaymentQuery.status, + paymentCreatedAt: latestPaymentQuery.createdAt, + }) + .from(user) + .leftJoin( + latestPaymentQuery, + and( + eq(user.id, latestPaymentQuery.userId), + eq(latestPaymentQuery.rowNumber, 1) + ) + ) + .where(or(isNull(user.banned), eq(user.banned, false))); + + console.log('distribute credits, users count:', usersWithPayments.length); + + const usersCount = usersWithPayments.length; + let processedCount = 0; + let errorCount = 0; + + // Separate users by their plan type for batch processing + const freeUserIds: string[] = []; + const lifetimeUsers: Array<{ userId: string; priceId: string }> = []; + const yearlyUsers: Array<{ userId: string; priceId: string }> = []; + + usersWithPayments.forEach((userRecord) => { + // Check if user has active subscription (status is 'active' or 'trialing') + if ( + userRecord.priceId && + userRecord.paymentStatus && + (userRecord.paymentStatus === 'active' || + userRecord.paymentStatus === 'trialing') + ) { + // User has active subscription - check what type + const pricePlan = findPlanByPriceId(userRecord.priceId); + if (pricePlan?.isLifetime && pricePlan?.credits?.enable) { + lifetimeUsers.push({ + userId: userRecord.userId, + priceId: userRecord.priceId, + }); + } else if (!pricePlan?.isFree && pricePlan?.credits?.enable) { + // Check if this is a yearly subscription that needs monthly credits + const yearlyPrice = pricePlan?.prices?.find( + (p) => + p.priceId === userRecord.priceId && + p.interval === PlanIntervals.YEAR + ); + if (yearlyPrice) { + yearlyUsers.push({ + userId: userRecord.userId, + priceId: userRecord.priceId, + }); + } + // Monthly subscriptions are handled by Stripe webhooks automatically + } + } else { + // User has no active subscription - add free monthly credits if enabled + freeUserIds.push(userRecord.userId); + } + }); + + console.log( + `distribute credits, lifetime users: ${lifetimeUsers.length}, free users: ${freeUserIds.length}, yearly users: ${yearlyUsers.length}` + ); + + const batchSize = 100; + + // Process free users in batches + for (let i = 0; i < freeUserIds.length; i += batchSize) { + const batch = freeUserIds.slice(i, i + batchSize); + try { + await batchAddMonthlyFreeCredits(batch); + processedCount += batch.length; + } catch (error) { + console.error( + `batchAddMonthlyFreeCredits error for batch ${i / batchSize + 1}:`, + error + ); + errorCount += batch.length; + } + + // Log progress for large datasets + if (freeUserIds.length > 1000) { + console.log( + `free credits progress: ${Math.min(i + batchSize, freeUserIds.length)}/${freeUserIds.length}` + ); + } + } + + // Process lifetime users in batches + for (let i = 0; i < lifetimeUsers.length; i += batchSize) { + const batch = lifetimeUsers.slice(i, i + batchSize); + try { + await batchAddLifetimeMonthlyCredits(batch); + processedCount += batch.length; + } catch (error) { + console.error( + `batchAddLifetimeMonthlyCredits error for batch ${i / batchSize + 1}:`, + error + ); + errorCount += batch.length; + } + + // Log progress for large datasets + if (lifetimeUsers.length > 1000) { + console.log( + `lifetime credits progress: ${Math.min(i + batchSize, lifetimeUsers.length)}/${lifetimeUsers.length}` + ); + } + } + + // Process yearly subscription users in batches + for (let i = 0; i < yearlyUsers.length; i += batchSize) { + const batch = yearlyUsers.slice(i, i + batchSize); + try { + await batchAddYearlyUsersMonthlyCredits(batch); + processedCount += batch.length; + } catch (error) { + console.error( + `batchAddYearlyUsersMonthlyCredits error for batch ${i / batchSize + 1}:`, + error + ); + errorCount += batch.length; + } + + // Log progress for large datasets + if (yearlyUsers.length > 1000) { + console.log( + `yearly subscription credits progress: ${Math.min(i + batchSize, yearlyUsers.length)}/${yearlyUsers.length}` + ); + } + } + + console.log( + `<<< distribute credits end, users: ${usersCount}, processed: ${processedCount}, errors: ${errorCount}` + ); + return { usersCount, processedCount, errorCount }; +} + +/** + * Batch add monthly free credits to multiple users + * @param userIds - Array of user IDs + */ +export async function batchAddMonthlyFreeCredits(userIds: string[]) { + if (userIds.length === 0) { + console.log('batchAddMonthlyFreeCredits, no users to add credits'); + return; + } + + // NOTICE: make sure the free plan is not disabled and has credits enabled + const pricePlans = getAllPricePlans(); + const freePlan = pricePlans.find( + (plan) => + plan.isFree && + !plan.disabled && + plan.credits?.enable && + plan.credits?.amount > 0 + ); + if (!freePlan) { + console.log('batchAddMonthlyFreeCredits, no available free plan'); + return; + } + + const db = await getDb(); + const now = new Date(); + const credits = freePlan.credits?.amount || 0; + const expireDays = freePlan.credits?.expireDays || 0; + + // Use transaction for data consistency + let processedCount = 0; + await db.transaction(async (tx) => { + // Get all user credit records in one query + const userCredits = await tx + .select({ + userId: userCredit.userId, + lastRefreshAt: userCredit.lastRefreshAt, + currentCredits: userCredit.currentCredits, + }) + .from(userCredit) + .where(inArray(userCredit.userId, userIds)); + + // Create a map for quick lookup + const userCreditMap = new Map( + userCredits.map((record) => [record.userId, record]) + ); + + // Filter users who can receive credits + const eligibleUserIds = userIds.filter((userId) => { + const record = userCreditMap.get(userId); + if (!record?.lastRefreshAt) { + return true; // never added credits before + } + // different month or year means new month + const last = new Date(record.lastRefreshAt); + return ( + now.getMonth() !== last.getMonth() || + now.getFullYear() !== last.getFullYear() + ); + }); + + if (eligibleUserIds.length === 0) { + console.log('batchAddMonthlyFreeCredits, no eligible users'); + return; + } + + processedCount = eligibleUserIds.length; + const expirationDate = expireDays ? addDays(now, expireDays) : undefined; + + // Batch insert credit transactions + const transactions = eligibleUserIds.map((userId) => ({ + id: randomUUID(), + userId, + type: CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH, + amount: credits, + remainingAmount: credits, + description: `Free monthly credits: ${credits} for ${now.getFullYear()}-${now.getMonth() + 1}`, + expirationDate, + createdAt: now, + updatedAt: now, + })); + + await tx.insert(creditTransaction).values(transactions); + + // Prepare user credit updates + const existingUserIds = eligibleUserIds.filter((userId) => + userCreditMap.has(userId) + ); + const newUserIds = eligibleUserIds.filter( + (userId) => !userCreditMap.has(userId) + ); + + // Insert new user credit records + if (newUserIds.length > 0) { + const newRecords = newUserIds.map((userId) => ({ + id: randomUUID(), + userId, + currentCredits: credits, + lastRefreshAt: now, + createdAt: now, + updatedAt: now, + })); + await tx.insert(userCredit).values(newRecords); + } + + // Update existing user credit records + if (existingUserIds.length > 0) { + // Update each user individually to add credits to their existing balance + for (const userId of existingUserIds) { + const currentRecord = userCreditMap.get(userId); + const newBalance = (currentRecord?.currentCredits || 0) + credits; + await tx + .update(userCredit) + .set({ + currentCredits: newBalance, + lastRefreshAt: now, + updatedAt: now, + }) + .where(eq(userCredit.userId, userId)); + } + } + }); + + console.log( + `batchAddMonthlyFreeCredits, ${credits} credits for ${processedCount} users, date: ${now.getFullYear()}-${now.getMonth() + 1}` + ); +} + +/** + * Batch add lifetime monthly credits to multiple users + * @param users - Array of user objects with userId and priceId + */ +export async function batchAddLifetimeMonthlyCredits( + users: Array<{ userId: string; priceId: string }> +) { + if (users.length === 0) { + console.log('batchAddLifetimeMonthlyCredits, no users to add credits'); + return; + } + + const db = await getDb(); + const now = new Date(); + + // Group users by their priceId to handle different lifetime plans + const usersByPriceId = new Map(); + users.forEach((user) => { + if (!usersByPriceId.has(user.priceId)) { + usersByPriceId.set(user.priceId, []); + } + usersByPriceId.get(user.priceId)!.push(user.userId); + }); + + let totalProcessedCount = 0; + + // Process each priceId group separately + for (const [priceId, userIdsForPrice] of usersByPriceId) { + const pricePlan = findPlanByPriceId(priceId); + if ( + !pricePlan || + !pricePlan.isLifetime || + // pricePlan.disabled || + !pricePlan.credits?.enable || + !pricePlan.credits?.amount + ) { + console.log( + `batchAddLifetimeMonthlyCredits, invalid plan for priceId: ${priceId}` + ); + continue; + } + + const credits = pricePlan.credits.amount; + const expireDays = pricePlan.credits.expireDays; + + // Use transaction for data consistency + let processedCount = 0; + await db.transaction(async (tx) => { + // Get all user credit records in one query + const userCredits = await tx + .select({ + userId: userCredit.userId, + lastRefreshAt: userCredit.lastRefreshAt, + currentCredits: userCredit.currentCredits, + }) + .from(userCredit) + .where(inArray(userCredit.userId, userIdsForPrice)); + + // Create a map for quick lookup + const userCreditMap = new Map( + userCredits.map((record) => [record.userId, record]) + ); + + // Filter users who can receive credits + const eligibleUserIds = userIdsForPrice.filter((userId: string) => { + const record = userCreditMap.get(userId); + if (!record?.lastRefreshAt) { + return true; // never added credits before + } + // different month or year means new month + const last = new Date(record.lastRefreshAt); + return ( + now.getMonth() !== last.getMonth() || + now.getFullYear() !== last.getFullYear() + ); + }); + + if (eligibleUserIds.length === 0) { + console.log( + `batchAddLifetimeMonthlyCredits, no eligible users for priceId: ${priceId}` + ); + return; + } + + processedCount = eligibleUserIds.length; + const expirationDate = expireDays ? addDays(now, expireDays) : undefined; + + // Batch insert credit transactions + const transactions = eligibleUserIds.map((userId: string) => ({ + id: randomUUID(), + userId, + type: CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY, + amount: credits, + remainingAmount: credits, + description: `Lifetime monthly credits: ${credits} for ${now.getFullYear()}-${now.getMonth() + 1}`, + expirationDate, + createdAt: now, + updatedAt: now, + })); + + await tx.insert(creditTransaction).values(transactions); + + // Prepare user credit updates + const existingUserIds = eligibleUserIds.filter((userId: string) => + userCreditMap.has(userId) + ); + const newUserIds = eligibleUserIds.filter( + (userId: string) => !userCreditMap.has(userId) + ); + + // Insert new user credit records + if (newUserIds.length > 0) { + const newRecords = newUserIds.map((userId: string) => ({ + id: randomUUID(), + userId, + currentCredits: credits, + lastRefreshAt: now, + createdAt: now, + updatedAt: now, + })); + await tx.insert(userCredit).values(newRecords); + } + + // Update existing user credit records + if (existingUserIds.length > 0) { + // Update each user individually to add credits to their existing balance + for (const userId of existingUserIds) { + const currentRecord = userCreditMap.get(userId); + const newBalance = (currentRecord?.currentCredits || 0) + credits; + await tx + .update(userCredit) + .set({ + currentCredits: newBalance, + lastRefreshAt: now, + updatedAt: now, + }) + .where(eq(userCredit.userId, userId)); + } + } + }); + + totalProcessedCount += processedCount; + console.log( + `batchAddLifetimeMonthlyCredits, ${credits} credits for ${processedCount} users with priceId ${priceId}, date: ${now.getFullYear()}-${now.getMonth() + 1}` + ); + } + + console.log( + `batchAddLifetimeMonthlyCredits, total processed: ${totalProcessedCount} users` + ); +} + +/** + * Batch add monthly credits to yearly subscription users + * @param users - Array of user objects with userId and priceId + */ +export async function batchAddYearlyUsersMonthlyCredits( + users: Array<{ userId: string; priceId: string }> +) { + if (users.length === 0) { + console.log('batchAddYearlyUsersMonthlyCredits, no users to add credits'); + return; + } + + const db = await getDb(); + const now = new Date(); + + // Group users by priceId to batch process users with the same plan + const usersByPriceId = new Map(); + users.forEach(({ userId, priceId }) => { + if (!usersByPriceId.has(priceId)) { + usersByPriceId.set(priceId, []); + } + usersByPriceId.get(priceId)!.push(userId); + }); + + let totalProcessedCount = 0; + + // Process each price group + for (const [priceId, userIds] of usersByPriceId) { + const pricePlan = findPlanByPriceId(priceId); + if (!pricePlan || !pricePlan.credits || !pricePlan.credits.enable) { + console.log( + `batchAddYearlyUsersMonthlyCredits, plan disabled or credits disabled for priceId: ${priceId}` + ); + continue; + } + + const credits = pricePlan.credits.amount; + const expireDays = pricePlan.credits.expireDays; + + // Use transaction for data consistency + let processedCount = 0; + await db.transaction(async (tx) => { + // Get all user credit records in one query + const userCredits = await tx + .select({ + userId: userCredit.userId, + lastRefreshAt: userCredit.lastRefreshAt, + currentCredits: userCredit.currentCredits, + }) + .from(userCredit) + .where(inArray(userCredit.userId, userIds)); + + // Create a map for quick lookup + const userCreditMap = new Map( + userCredits.map((record) => [record.userId, record]) + ); + + // Filter users who can receive credits + const eligibleUserIds = userIds.filter((userId) => { + const record = userCreditMap.get(userId); + if (!record?.lastRefreshAt) { + return true; // never added credits before + } + // different month or year means new month + const last = new Date(record.lastRefreshAt); + return ( + now.getMonth() !== last.getMonth() || + now.getFullYear() !== last.getFullYear() + ); + }); + + if (eligibleUserIds.length === 0) { + console.log( + `batchAddYearlyUsersMonthlyCredits, no eligible users for priceId: ${priceId}` + ); + return; + } + + processedCount = eligibleUserIds.length; + const expirationDate = expireDays ? addDays(now, expireDays) : undefined; + + // Batch insert credit transactions + const transactions = eligibleUserIds.map((userId) => ({ + id: randomUUID(), + userId, + type: CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL, + amount: credits, + remainingAmount: credits, + description: `Yearly subscription monthly credits: ${credits} for ${now.getFullYear()}-${now.getMonth() + 1}`, + expirationDate, + createdAt: now, + updatedAt: now, + })); + + await tx.insert(creditTransaction).values(transactions); + + // Prepare user credit updates + const existingUserIds = eligibleUserIds.filter((userId) => + userCreditMap.has(userId) + ); + const newUserIds = eligibleUserIds.filter( + (userId) => !userCreditMap.has(userId) + ); + + // Insert new user credit records + if (newUserIds.length > 0) { + const newRecords = newUserIds.map((userId) => ({ + id: randomUUID(), + userId, + currentCredits: credits, + lastRefreshAt: now, + createdAt: now, + updatedAt: now, + })); + await tx.insert(userCredit).values(newRecords); + } + + // Update existing user credit records + if (existingUserIds.length > 0) { + // Update each user individually to add credits to their existing balance + for (const userId of existingUserIds) { + const currentRecord = userCreditMap.get(userId); + const newBalance = (currentRecord?.currentCredits || 0) + credits; + await tx + .update(userCredit) + .set({ + currentCredits: newBalance, + lastRefreshAt: now, + updatedAt: now, + }) + .where(eq(userCredit.userId, userId)); + } + } + }); + + totalProcessedCount += processedCount; + console.log( + `batchAddYearlyUsersMonthlyCredits, ${credits} credits for ${processedCount} users with priceId: ${priceId}, date: ${now.getFullYear()}-${now.getMonth() + 1}` + ); + } + + console.log( + `batchAddYearlyUsersMonthlyCredits completed, total processed: ${totalProcessedCount} users` + ); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 5cbfb47..a7a9293 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -194,33 +194,23 @@ async function onCreateUser(user: User) { ) { try { await addRegisterGiftCredits(user.id); - const credits = websiteConfig.credits.registerGiftCredits.credits; - console.log( - `added register gift credits for user ${user.id}, credits: ${credits}` - ); + console.log(`added register gift credits for user ${user.id}`); } catch (error) { console.error('Register gift credits error:', error); } } // Add free monthly credits to the user if enabled in website config - if ( - websiteConfig.credits.enableCredits && - websiteConfig.credits.enableForFreePlan - ) { - const pricePlans = await getAllPricePlans(); - const freePlan = pricePlans.find((plan) => plan.isFree); - if ( - freePlan?.credits?.enable && - freePlan?.credits?.amount && - freePlan?.credits?.amount > 0 - ) { + if (websiteConfig.credits.enableCredits) { + const pricePlans = getAllPricePlans(); + // NOTICE: make sure the free plan is not disabled and has credits enabled + const freePlan = pricePlans.find( + (plan) => plan.isFree && !plan.disabled && plan.credits?.enable + ); + if (freePlan) { try { - await addMonthlyFreeCredits(user.id); - const credits = freePlan.credits.amount; - console.log( - `added free monthly credits for user ${user.id}, credits: ${credits}` - ); + await addMonthlyFreeCredits(user.id, freePlan.id); + console.log(`added Free monthly credits for user ${user.id}`); } catch (error) { console.error('Free monthly credits error:', error); } diff --git a/src/payment/provider/stripe.ts b/src/payment/provider/stripe.ts index 1f0cc7e..866fb60 100644 --- a/src/payment/provider/stripe.ts +++ b/src/payment/provider/stripe.ts @@ -578,13 +578,10 @@ export class StripeProvider implements PaymentProvider { console.warn('<< No payment record created for Stripe subscription'); } - // Conditionally handle credits after subscription creation + // Conditionally handle credits after subscription creation if enables credits if (websiteConfig.credits?.enableCredits) { - // Add subscription renewal credits if plan config enables credits - const pricePlan = findPlanByPriceId(priceId); - if (pricePlan?.credits?.enable) { - await addSubscriptionCredits(userId, priceId); - } + await addSubscriptionCredits(userId, priceId); + console.log('<< Added subscription monthly credits for user'); } } @@ -662,21 +659,13 @@ export class StripeProvider implements PaymentProvider { // Add credits for subscription renewal const currentPayment = payments[0]; - if ( - isRenewal && - currentPayment.userId && - websiteConfig.credits?.enableCredits - ) { - // Add subscription renewal credits if plan config enables credits - const pricePlan = findPlanByPriceId(priceId); - if (pricePlan?.credits?.enable) { - try { - await addSubscriptionCredits(currentPayment.userId, priceId); - console.log('<< Added renewal credits for user'); - } catch (error) { - console.error('<< Failed to add renewal credits for user:', error); - } - } + const userId = currentPayment.userId; + // Add subscription renewal credits if plan config enables credits + if (isRenewal && userId && websiteConfig.credits?.enableCredits) { + // Note: For yearly subscriptions, this webhook only triggers once per year + // Monthly credits for yearly subscribers are handled by the distributeCreditsToAllUsers cron job + await addSubscriptionCredits(userId, priceId); + console.log('<< Added subscription renewal credits for user'); } else { console.log( '<< No renewal credits added for user, isRenewal: ' + isRenewal @@ -784,15 +773,9 @@ export class StripeProvider implements PaymentProvider { // Conditionally handle credits after one-time payment if (websiteConfig.credits?.enableCredits) { - // If the plan is lifetime and credits are enabled, add lifetime monthly credits if needed - const lifetimePlan = Object.values( - websiteConfig.price?.plans || {} - ).find( - (plan) => plan.isLifetime && !plan.disabled && plan.credits?.enable - ); - if (lifetimePlan?.prices?.some((p) => p.priceId === priceId)) { - await addLifetimeMonthlyCredits(userId); - } + // For now, one time payment is only for lifetime plan + await addLifetimeMonthlyCredits(userId, priceId); + console.log('<< Added lifetime monthly credits for user'); } // Send notification