Merge remote-tracking branch 'origin/main' into cloudflare
This commit is contained in:
commit
f468638f49
@ -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,
|
||||
});
|
||||
|
@ -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
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 {
|
||||
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);
|
||||
}
|
||||
|
@ -584,13 +584,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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -668,21 +665,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
|
||||
@ -790,15 +779,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
|
||||
|
Loading…
Reference in New Issue
Block a user