refactor: restructure credit distribution logic and enhance user credit handling

This commit is contained in:
javayhu 2025-08-17 23:26:10 +08:00
parent bade6b620e
commit f1d02720d0
5 changed files with 736 additions and 510 deletions

View File

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

View File

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

604
src/credits/distribute.ts Normal file
View File

@ -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<number>`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<string, string[]>();
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<string, string[]>();
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`
);
}

View File

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

View File

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