feat: enhance credits management with subscription renewal and lifetime monthly credits

This commit is contained in:
javayhu 2025-07-10 14:42:36 +08:00
parent 74d7cf44a1
commit 737bd7f80f
7 changed files with 203 additions and 31 deletions

View File

@ -90,6 +90,11 @@ export const websiteConfig: WebsiteConfig = {
prices: [], prices: [],
isFree: true, isFree: true,
isLifetime: false, isLifetime: false,
credits: {
enable: true,
amount: 50,
expireDays: 30,
},
}, },
pro: { pro: {
id: 'pro', id: 'pro',
@ -112,6 +117,11 @@ export const websiteConfig: WebsiteConfig = {
isFree: false, isFree: false,
isLifetime: false, isLifetime: false,
recommended: true, recommended: true,
credits: {
enable: true,
amount: 1000,
expireDays: 90,
},
}, },
lifetime: { lifetime: {
id: 'lifetime', id: 'lifetime',
@ -126,6 +136,11 @@ export const websiteConfig: WebsiteConfig = {
], ],
isFree: false, isFree: false,
isLifetime: true, isLifetime: true,
credits: {
enable: true,
amount: 2000,
expireDays: 120,
},
}, },
}, },
}, },
@ -136,11 +151,7 @@ export const websiteConfig: WebsiteConfig = {
credits: 100, credits: 100,
expireDays: 30, expireDays: 30,
}, },
freeMonthlyCredits: {
enable: true,
credits: 50,
expireDays: 30,
},
packages: { packages: {
basic: { basic: {
id: 'basic', id: 'basic',
@ -182,7 +193,7 @@ export const websiteConfig: WebsiteConfig = {
id: 'enterprise', id: 'enterprise',
popular: false, popular: false,
credits: 1000, credits: 1000,
expireDays: 180, expireDays: 120,
price: { price: {
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_ENTERPRISE!, priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_ENTERPRISE!,
amount: 6990, amount: 6990,

View File

@ -1,9 +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, userCredit } from '@/db/schema'; import { creditTransaction, payment, user, userCredit } from '@/db/schema';
import { findPlanByPriceId } from '@/lib/price-plan';
import { addDays, isAfter } from 'date-fns'; import { addDays, isAfter } from 'date-fns';
import { and, asc, eq, or } from 'drizzle-orm'; import { and, asc, desc, eq, or } from 'drizzle-orm';
import { CREDIT_TRANSACTION_TYPE } from './types'; import { CREDIT_TRANSACTION_TYPE } from './types';
/** /**
@ -384,8 +385,18 @@ export async function addRegisterGiftCredits(userId: string) {
* @param userId - User ID * @param userId - User ID
*/ */
export async function addMonthlyFreeCredits(userId: string) { export async function addMonthlyFreeCredits(userId: string) {
if (!websiteConfig.credits.freeMonthlyCredits.enable) { const freePlan = Object.values(websiteConfig.price.plans).find(
console.log('addMonthlyFreeCredits, disabled'); (plan) => plan.isFree
);
if (!freePlan) {
console.log('addMonthlyFreeCredits, no free plan found');
return;
}
if (freePlan.disabled || !freePlan.credits?.enable) {
console.log(
'addMonthlyFreeCredits, plan disabled or credits disabled',
freePlan.id
);
return; return;
} }
// Check last refresh time // Check last refresh time
@ -402,14 +413,15 @@ export async function addMonthlyFreeCredits(userId: string) {
canAdd = true; canAdd = true;
} else { } else {
const last = new Date(record[0].lastRefreshAt); const last = new Date(record[0].lastRefreshAt);
// different month or year means new month
canAdd = canAdd =
now.getMonth() !== last.getMonth() || now.getMonth() !== last.getMonth() ||
now.getFullYear() !== last.getFullYear(); 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 = websiteConfig.credits.freeMonthlyCredits.credits; const credits = freePlan.credits.amount;
const expireDays = websiteConfig.credits.freeMonthlyCredits.expireDays; const expireDays = freePlan.credits.expireDays;
await addCredits({ await addCredits({
userId, userId,
amount: credits, amount: credits,
@ -419,3 +431,104 @@ export async function addMonthlyFreeCredits(userId: string) {
}); });
} }
} }
/**
* Add subscription renewal credits
* @param userId - User ID
* @param priceId - Price ID
*/
export async function addSubscriptionRenewalCredits(
userId: string,
priceId: string
) {
const pricePlan = findPlanByPriceId(priceId);
if (
!pricePlan ||
pricePlan.isFree ||
!pricePlan.credits ||
!pricePlan.credits.enable
) {
console.log(
`addSubscriptionRenewalCredits, no credits configured for plan ${priceId}`
);
return;
}
const credits = pricePlan.credits.amount;
const expireDays = pricePlan.credits.expireDays;
await addCredits({
userId,
amount: credits,
type: CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL,
description: `Subscription renewal credits for ${priceId}: ${credits}`,
expireDays,
});
console.log(
`Added ${credits} subscription renewal credits for user ${userId}, priceId: ${priceId}`
);
}
/**
* Add lifetime monthly credits
* @param userId - User ID
*/
export async function addLifetimeMonthlyCredits(userId: string) {
const lifetimePlan = Object.values(websiteConfig.price.plans).find(
(plan) => plan.isLifetime
);
if (
!lifetimePlan ||
lifetimePlan.disabled ||
!lifetimePlan.credits ||
!lifetimePlan.credits.enable
) {
console.log(
'addLifetimeMonthlyCredits, plan disabled or credits disabled',
lifetimePlan?.id
);
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 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;
await addCredits({
userId,
amount: credits,
type: CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY,
description: `Lifetime monthly credits: ${credits} for ${now.getFullYear()}-${now.getMonth() + 1}`,
expireDays,
});
// Update last refresh time for lifetime credits
await updateUserLastRefreshAt(userId, now);
console.log(`Added ${credits} lifetime monthly credits for user ${userId}`);
}
}

View File

@ -2,11 +2,13 @@
* Credit transaction type enum * Credit transaction type enum
*/ */
export enum CREDIT_TRANSACTION_TYPE { export enum CREDIT_TRANSACTION_TYPE {
MONTHLY_REFRESH = 'MONTHLY_REFRESH', // Credits earned by monthly refresh MONTHLY_REFRESH = 'MONTHLY_REFRESH', // Credits earned by monthly refresh (free users)
REGISTER_GIFT = 'REGISTER_GIFT', // Credits earned by register gift REGISTER_GIFT = 'REGISTER_GIFT', // Credits earned by register gift
PURCHASE = 'PURCHASE', // Credits earned by purchase PURCHASE = 'PURCHASE', // Credits earned by purchase
USAGE = 'USAGE', // Credits spent by usage SUBSCRIPTION_RENEWAL = 'SUBSCRIPTION_RENEWAL', // Credits earned by subscription renewal
EXPIRE = 'EXPIRE', // Credits expired LIFETIME_MONTHLY = 'LIFETIME_MONTHLY', // Credits earned by lifetime plan monthly distribution
USAGE = 'USAGE', // Credits spent by usage
EXPIRE = 'EXPIRE', // Credits expired
} }
/** /**

View File

@ -86,7 +86,7 @@ export const creditTransaction = pgTable("credit_transaction", {
description: text("description"), description: text("description"),
amount: integer("amount").notNull(), amount: integer("amount").notNull(),
remainingAmount: integer("remaining_amount"), remainingAmount: integer("remaining_amount"),
paymentId: text("payment_id"), // payment_intent_id paymentId: text("payment_id"),
expirationDate: timestamp("expiration_date"), expirationDate: timestamp("expiration_date"),
expirationDateProcessedAt: timestamp("expiration_date_processed_at"), expirationDateProcessedAt: timestamp("expiration_date_processed_at"),
createdAt: timestamp("created_at").notNull().defaultNow(), createdAt: timestamp("created_at").notNull().defaultNow(),

View File

@ -1,5 +1,5 @@
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { addCredits } from '@/credits/credits'; import { addCredits, addSubscriptionRenewalCredits } from '@/credits/credits';
import { getCreditPackageByIdInServer } from '@/credits/server'; import { getCreditPackageByIdInServer } from '@/credits/server';
import { CREDIT_TRANSACTION_TYPE } from '@/credits/types'; import { CREDIT_TRANSACTION_TYPE } from '@/credits/types';
import { getDb } from '@/db'; import { getDb } from '@/db';
@ -608,6 +608,34 @@ export class StripeProvider implements PaymentProvider {
return; return;
} }
// Get current payment record to check for period changes (indicating renewal)
const db = await getDb();
const currentPayment = await db
.select({
userId: payment.userId,
periodStart: payment.periodStart,
periodEnd: payment.periodEnd,
})
.from(payment)
.where(eq(payment.subscriptionId, stripeSubscription.id))
.limit(1);
// get new period start and end
const newPeriodStart = stripeSubscription.current_period_start
? new Date(stripeSubscription.current_period_start * 1000)
: undefined;
const newPeriodEnd = stripeSubscription.current_period_end
? new Date(stripeSubscription.current_period_end * 1000)
: undefined;
// Check if this is a renewal (period has changed and subscription is active)
const isRenewal =
currentPayment.length > 0 &&
stripeSubscription.status === 'active' &&
currentPayment[0].periodStart &&
newPeriodStart &&
currentPayment[0].periodStart.getTime() !== newPeriodStart.getTime();
// update fields // update fields
const updateFields: any = { const updateFields: any = {
priceId: priceId, priceId: priceId,
@ -615,12 +643,8 @@ export class StripeProvider implements PaymentProvider {
status: this.mapSubscriptionStatusToPaymentStatus( status: this.mapSubscriptionStatusToPaymentStatus(
stripeSubscription.status stripeSubscription.status
), ),
periodStart: stripeSubscription.current_period_start periodStart: newPeriodStart,
? new Date(stripeSubscription.current_period_start * 1000) periodEnd: newPeriodEnd,
: undefined,
periodEnd: stripeSubscription.current_period_end
? new Date(stripeSubscription.current_period_end * 1000)
: undefined,
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end, cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
trialStart: stripeSubscription.trial_start trialStart: stripeSubscription.trial_start
? new Date(stripeSubscription.trial_start * 1000) ? new Date(stripeSubscription.trial_start * 1000)
@ -631,7 +655,6 @@ export class StripeProvider implements PaymentProvider {
updatedAt: new Date(), updatedAt: new Date(),
}; };
const db = await getDb();
const result = await db const result = await db
.update(payment) .update(payment)
.set(updateFields) .set(updateFields)
@ -642,6 +665,24 @@ export class StripeProvider implements PaymentProvider {
console.log( console.log(
`<< Updated payment record ${result[0].id} for Stripe subscription ${stripeSubscription.id}` `<< Updated payment record ${result[0].id} for Stripe subscription ${stripeSubscription.id}`
); );
// Add credits for subscription renewal
if (isRenewal && currentPayment[0].userId) {
try {
await addSubscriptionRenewalCredits(
currentPayment[0].userId,
priceId
);
console.log(
`<< Added renewal credits for user ${currentPayment[0].userId}, priceId: ${priceId}`
);
} catch (error) {
console.error(
`<< Failed to add renewal credits for user ${currentPayment[0].userId}:`,
error
);
}
}
} else { } else {
console.warn( console.warn(
`<< No payment record found for Stripe subscription ${stripeSubscription.id}` `<< No payment record found for Stripe subscription ${stripeSubscription.id}`

View File

@ -50,6 +50,15 @@ export interface Price {
disabled?: boolean; // Whether to disable this price in UI disabled?: boolean; // Whether to disable this price in UI
} }
/**
* Credits configuration for a plan
*/
export interface Credits {
enable: boolean; // Whether to enable credits for this plan
amount: number; // Number of credits provided
expireDays?: number; // Number of days until credits expire, undefined means no expiration
}
/** /**
* Price plan definition * Price plan definition
* *
@ -72,6 +81,7 @@ export interface PricePlan {
isLifetime: boolean; // Whether this is a lifetime plan isLifetime: boolean; // Whether this is a lifetime plan
recommended?: boolean; // Whether to mark this plan as recommended in UI recommended?: boolean; // Whether to mark this plan as recommended in UI
disabled?: boolean; // Whether to disable this plan in UI disabled?: boolean; // Whether to disable this plan in UI
credits?: Credits; // Credits configuration for this plan
} }
/** /**

View File

@ -160,11 +160,6 @@ export interface CreditsConfig {
credits: number; // The number of credits to give to the user credits: number; // The number of credits to give to the user
expireDays?: number; // The number of days to expire the credits, undefined means no expire expireDays?: number; // The number of days to expire the credits, undefined means no expire
}; };
freeMonthlyCredits: {
enable: boolean; // Whether to enable free monthly credits
credits: number; // The number of credits to give to the user
expireDays?: number; // The number of days to expire the credits, undefined means no expire
};
packages: Record<string, CreditPackage>; // Packages indexed by ID packages: Record<string, CreditPackage>; // Packages indexed by ID
} }