refactor: restructure credit distribution logic and enhance user credit handling
This commit is contained in:
parent
bade6b620e
commit
f1d02720d0
@ -1,4 +1,4 @@
|
|||||||
import { distributeCreditsToAllUsers } from '@/credits/credits';
|
import { distributeCreditsToAllUsers } from '@/credits/distribute';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
// Basic authentication middleware
|
// Basic authentication middleware
|
||||||
@ -45,13 +45,15 @@ export async function GET(request: Request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('distribute credits start');
|
console.log('route: distribute credits start');
|
||||||
const { processedCount, errorCount } = await distributeCreditsToAllUsers();
|
const { usersCount, processedCount, errorCount } =
|
||||||
|
await distributeCreditsToAllUsers();
|
||||||
console.log(
|
console.log(
|
||||||
`distribute credits end, processed: ${processedCount}, errors: ${errorCount}`
|
`route: distribute credits end, users: ${usersCount}, processed: ${processedCount}, errors: ${errorCount}`
|
||||||
);
|
);
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: `distribute credits success, processed: ${processedCount}, errors: ${errorCount}`,
|
message: `distribute credits success, users: ${usersCount}, processed: ${processedCount}, errors: ${errorCount}`,
|
||||||
|
usersCount,
|
||||||
processedCount,
|
processedCount,
|
||||||
errorCount,
|
errorCount,
|
||||||
});
|
});
|
||||||
|
@ -1,11 +1,10 @@
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { websiteConfig } from '@/config/website';
|
import { websiteConfig } from '@/config/website';
|
||||||
import { getDb } from '@/db';
|
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 { findPlanByPriceId } from '@/lib/price-plan';
|
||||||
import { addDays, isAfter } from 'date-fns';
|
import { addDays, isAfter } from 'date-fns';
|
||||||
import { and, asc, desc, eq, gt, inArray, isNull, not, or } from 'drizzle-orm';
|
import { and, asc, eq, gt, isNull, not, or } from 'drizzle-orm';
|
||||||
import { sql } from 'drizzle-orm';
|
|
||||||
import { CREDIT_TRANSACTION_TYPE } from './types';
|
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({
|
export async function hasEnoughCredits({
|
||||||
userId,
|
userId,
|
||||||
requiredCredits,
|
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
|
* Add register gift credits
|
||||||
* @param userId - User ID
|
* @param userId - User ID
|
||||||
*/
|
*/
|
||||||
export async function addRegisterGiftCredits(userId: string) {
|
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
|
// Check if user has already received register gift credits
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const record = await db
|
const record = await db
|
||||||
@ -376,6 +405,7 @@ export async function addRegisterGiftCredits(userId: string) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
// add register gift credits if user has not received them yet
|
// add register gift credits if user has not received them yet
|
||||||
if (record.length === 0) {
|
if (record.length === 0) {
|
||||||
const credits = websiteConfig.credits.registerGiftCredits.credits;
|
const credits = websiteConfig.credits.registerGiftCredits.credits;
|
||||||
@ -397,49 +427,31 @@ export async function addRegisterGiftCredits(userId: string) {
|
|||||||
/**
|
/**
|
||||||
* Add free monthly credits
|
* Add free monthly credits
|
||||||
* @param userId - User ID
|
* @param userId - User ID
|
||||||
|
* @param priceId - Price ID
|
||||||
*/
|
*/
|
||||||
export async function addMonthlyFreeCredits(userId: string) {
|
export async function addMonthlyFreeCredits(userId: string, priceId: string) {
|
||||||
const freePlan = Object.values(websiteConfig.price.plans).find(
|
// NOTICE: make sure the free plan is not disabled and has credits enabled
|
||||||
(plan) => plan.isFree && !plan.disabled
|
const pricePlan = findPlanByPriceId(priceId);
|
||||||
);
|
|
||||||
if (!freePlan) {
|
|
||||||
console.log('addMonthlyFreeCredits, no free plan found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
freePlan.disabled ||
|
!pricePlan ||
|
||||||
!freePlan.credits?.enable ||
|
pricePlan.disabled ||
|
||||||
!freePlan.credits?.amount
|
!pricePlan.isFree ||
|
||||||
|
!pricePlan.credits ||
|
||||||
|
!pricePlan.credits.enable
|
||||||
) {
|
) {
|
||||||
console.log(
|
console.log(
|
||||||
'addMonthlyFreeCredits, plan disabled or credits disabled',
|
`addMonthlyFreeCredits, no credits configured for plan ${priceId}`
|
||||||
freePlan.id
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Check last refresh time
|
|
||||||
const db = await getDb();
|
const canAdd = await canAddMonthlyCredits(userId);
|
||||||
const record = await db
|
|
||||||
.select()
|
|
||||||
.from(userCredit)
|
|
||||||
.where(eq(userCredit.userId, userId))
|
|
||||||
.limit(1);
|
|
||||||
const now = new Date();
|
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
|
// add credits if it's a new month
|
||||||
if (canAdd) {
|
if (canAdd) {
|
||||||
const credits = freePlan.credits.amount;
|
const credits = pricePlan.credits?.amount || 0;
|
||||||
const expireDays = freePlan.credits.expireDays;
|
const expireDays = pricePlan.credits?.expireDays || 0;
|
||||||
await addCredits({
|
await addCredits({
|
||||||
userId,
|
userId,
|
||||||
amount: credits,
|
amount: credits,
|
||||||
@ -454,92 +466,93 @@ export async function addMonthlyFreeCredits(userId: string) {
|
|||||||
console.log(
|
console.log(
|
||||||
`addMonthlyFreeCredits, ${credits} credits for user ${userId}, date: ${now.getFullYear()}-${now.getMonth() + 1}`
|
`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 userId - User ID
|
||||||
* @param priceId - Price ID
|
* @param priceId - Price ID
|
||||||
*/
|
*/
|
||||||
export async function addSubscriptionCredits(userId: string, priceId: string) {
|
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);
|
const pricePlan = findPlanByPriceId(priceId);
|
||||||
if (
|
if (
|
||||||
!pricePlan ||
|
!pricePlan ||
|
||||||
pricePlan.isFree ||
|
// pricePlan.disabled ||
|
||||||
!pricePlan.credits ||
|
!pricePlan.credits ||
|
||||||
!pricePlan.credits.enable
|
!pricePlan.credits.enable
|
||||||
) {
|
) {
|
||||||
console.log(
|
console.log(
|
||||||
`addSubscriptionRenewalCredits, no credits configured for plan ${priceId}`
|
`addSubscriptionCredits, no credits configured for plan ${priceId}`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const credits = pricePlan.credits.amount;
|
const canAdd = await canAddMonthlyCredits(userId);
|
||||||
const expireDays = pricePlan.credits.expireDays;
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
await addCredits({
|
// Add credits if it's a new month
|
||||||
userId,
|
if (canAdd) {
|
||||||
amount: credits,
|
const credits = pricePlan.credits.amount;
|
||||||
type: CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL,
|
const expireDays = pricePlan.credits.expireDays;
|
||||||
description: `Subscription renewal credits: ${credits} for ${now.getFullYear()}-${now.getMonth() + 1}`,
|
|
||||||
expireDays,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(
|
await addCredits({
|
||||||
`addSubscriptionRenewalCredits, ${credits} credits for user ${userId}, priceId: ${priceId}`
|
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
|
* Add lifetime monthly credits
|
||||||
* @param userId - User ID
|
* @param userId - User ID
|
||||||
|
* @param priceId - Price ID
|
||||||
*/
|
*/
|
||||||
export async function addLifetimeMonthlyCredits(userId: string) {
|
export async function addLifetimeMonthlyCredits(
|
||||||
const lifetimePlan = Object.values(websiteConfig.price.plans).find(
|
userId: string,
|
||||||
(plan) => plan.isLifetime && !plan.disabled
|
priceId: string
|
||||||
);
|
) {
|
||||||
|
// NOTICE: make sure the lifetime plan is not disabled and has credits enabled
|
||||||
|
const pricePlan = findPlanByPriceId(priceId);
|
||||||
if (
|
if (
|
||||||
!lifetimePlan ||
|
!pricePlan ||
|
||||||
lifetimePlan.disabled ||
|
!pricePlan.isLifetime ||
|
||||||
!lifetimePlan.credits ||
|
pricePlan.disabled ||
|
||||||
!lifetimePlan.credits.enable
|
!pricePlan.credits ||
|
||||||
|
!pricePlan.credits.enable
|
||||||
) {
|
) {
|
||||||
console.log(
|
console.log(
|
||||||
'addLifetimeMonthlyCredits, plan disabled or credits disabled',
|
`addLifetimeMonthlyCredits, no credits configured for plan ${priceId}`
|
||||||
lifetimePlan?.id
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check last refresh time to avoid duplicate monthly credits
|
const canAdd = await canAddMonthlyCredits(userId);
|
||||||
const db = await getDb();
|
|
||||||
const record = await db
|
|
||||||
.select()
|
|
||||||
.from(userCredit)
|
|
||||||
.where(eq(userCredit.userId, userId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const now = new Date();
|
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
|
// Add credits if it's a new month
|
||||||
if (canAdd) {
|
if (canAdd) {
|
||||||
const credits = lifetimePlan.credits.amount;
|
const credits = pricePlan.credits.amount;
|
||||||
const expireDays = lifetimePlan.credits.expireDays;
|
const expireDays = pricePlan.credits.expireDays;
|
||||||
|
|
||||||
await addCredits({
|
await addCredits({
|
||||||
userId,
|
userId,
|
||||||
@ -555,375 +568,9 @@ export async function addLifetimeMonthlyCredits(userId: string) {
|
|||||||
console.log(
|
console.log(
|
||||||
`addLifetimeMonthlyCredits, ${credits} credits for user ${userId}, date: ${now.getFullYear()}-${now.getMonth() + 1}`
|
`addLifetimeMonthlyCredits, ${credits} credits for user ${userId}, date: ${now.getFullYear()}-${now.getMonth() + 1}`
|
||||||
);
|
);
|
||||||
}
|
} else {
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
) {
|
|
||||||
console.log(
|
console.log(
|
||||||
'batchAddMonthlyFreeCredits, plan disabled or credits disabled',
|
`addLifetimeMonthlyCredits, no new month for user ${userId}, date: ${now.getFullYear()}-${now.getMonth() + 1}`
|
||||||
freePlan.id
|
|
||||||
);
|
);
|
||||||
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
604
src/credits/distribute.ts
Normal 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`
|
||||||
|
);
|
||||||
|
}
|
@ -194,33 +194,23 @@ async function onCreateUser(user: User) {
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
await addRegisterGiftCredits(user.id);
|
await addRegisterGiftCredits(user.id);
|
||||||
const credits = websiteConfig.credits.registerGiftCredits.credits;
|
console.log(`added register gift credits for user ${user.id}`);
|
||||||
console.log(
|
|
||||||
`added register gift credits for user ${user.id}, credits: ${credits}`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Register gift credits error:', error);
|
console.error('Register gift credits error:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add free monthly credits to the user if enabled in website config
|
// Add free monthly credits to the user if enabled in website config
|
||||||
if (
|
if (websiteConfig.credits.enableCredits) {
|
||||||
websiteConfig.credits.enableCredits &&
|
const pricePlans = getAllPricePlans();
|
||||||
websiteConfig.credits.enableForFreePlan
|
// NOTICE: make sure the free plan is not disabled and has credits enabled
|
||||||
) {
|
const freePlan = pricePlans.find(
|
||||||
const pricePlans = await getAllPricePlans();
|
(plan) => plan.isFree && !plan.disabled && plan.credits?.enable
|
||||||
const freePlan = pricePlans.find((plan) => plan.isFree);
|
);
|
||||||
if (
|
if (freePlan) {
|
||||||
freePlan?.credits?.enable &&
|
|
||||||
freePlan?.credits?.amount &&
|
|
||||||
freePlan?.credits?.amount > 0
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
await addMonthlyFreeCredits(user.id);
|
await addMonthlyFreeCredits(user.id, freePlan.id);
|
||||||
const credits = freePlan.credits.amount;
|
console.log(`added Free monthly credits for user ${user.id}`);
|
||||||
console.log(
|
|
||||||
`added free monthly credits for user ${user.id}, credits: ${credits}`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Free monthly credits error:', error);
|
console.error('Free monthly credits error:', error);
|
||||||
}
|
}
|
||||||
|
@ -578,13 +578,10 @@ export class StripeProvider implements PaymentProvider {
|
|||||||
console.warn('<< No payment record created for Stripe subscription');
|
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) {
|
if (websiteConfig.credits?.enableCredits) {
|
||||||
// Add subscription renewal credits if plan config enables credits
|
await addSubscriptionCredits(userId, priceId);
|
||||||
const pricePlan = findPlanByPriceId(priceId);
|
console.log('<< Added subscription monthly credits for user');
|
||||||
if (pricePlan?.credits?.enable) {
|
|
||||||
await addSubscriptionCredits(userId, priceId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -662,21 +659,13 @@ export class StripeProvider implements PaymentProvider {
|
|||||||
|
|
||||||
// Add credits for subscription renewal
|
// Add credits for subscription renewal
|
||||||
const currentPayment = payments[0];
|
const currentPayment = payments[0];
|
||||||
if (
|
const userId = currentPayment.userId;
|
||||||
isRenewal &&
|
// Add subscription renewal credits if plan config enables credits
|
||||||
currentPayment.userId &&
|
if (isRenewal && userId && websiteConfig.credits?.enableCredits) {
|
||||||
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
|
||||||
// Add subscription renewal credits if plan config enables credits
|
await addSubscriptionCredits(userId, priceId);
|
||||||
const pricePlan = findPlanByPriceId(priceId);
|
console.log('<< Added subscription renewal credits for user');
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.log(
|
console.log(
|
||||||
'<< No renewal credits added for user, isRenewal: ' + isRenewal
|
'<< No renewal credits added for user, isRenewal: ' + isRenewal
|
||||||
@ -784,15 +773,9 @@ export class StripeProvider implements PaymentProvider {
|
|||||||
|
|
||||||
// Conditionally handle credits after one-time payment
|
// Conditionally handle credits after one-time payment
|
||||||
if (websiteConfig.credits?.enableCredits) {
|
if (websiteConfig.credits?.enableCredits) {
|
||||||
// If the plan is lifetime and credits are enabled, add lifetime monthly credits if needed
|
// For now, one time payment is only for lifetime plan
|
||||||
const lifetimePlan = Object.values(
|
await addLifetimeMonthlyCredits(userId, priceId);
|
||||||
websiteConfig.price?.plans || {}
|
console.log('<< Added lifetime monthly credits for user');
|
||||||
).find(
|
|
||||||
(plan) => plan.isLifetime && !plan.disabled && plan.credits?.enable
|
|
||||||
);
|
|
||||||
if (lifetimePlan?.prices?.some((p) => p.priceId === priceId)) {
|
|
||||||
await addLifetimeMonthlyCredits(userId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send notification
|
// Send notification
|
||||||
|
Loading…
Reference in New Issue
Block a user