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

View File

@ -1,9 +1,10 @@
import { randomUUID } from 'crypto';
import { websiteConfig } from '@/config/website';
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 { and, asc, eq, or } from 'drizzle-orm';
import { and, asc, desc, eq, or } from 'drizzle-orm';
import { CREDIT_TRANSACTION_TYPE } from './types';
/**
@ -384,8 +385,18 @@ export async function addRegisterGiftCredits(userId: string) {
* @param userId - User ID
*/
export async function addMonthlyFreeCredits(userId: string) {
if (!websiteConfig.credits.freeMonthlyCredits.enable) {
console.log('addMonthlyFreeCredits, disabled');
const freePlan = Object.values(websiteConfig.price.plans).find(
(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;
}
// Check last refresh time
@ -402,14 +413,15 @@ export async function addMonthlyFreeCredits(userId: string) {
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 = websiteConfig.credits.freeMonthlyCredits.credits;
const expireDays = websiteConfig.credits.freeMonthlyCredits.expireDays;
const credits = freePlan.credits.amount;
const expireDays = freePlan.credits.expireDays;
await addCredits({
userId,
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
*/
export enum CREDIT_TRANSACTION_TYPE {
MONTHLY_REFRESH = 'MONTHLY_REFRESH', // Credits earned by monthly refresh
REGISTER_GIFT = 'REGISTER_GIFT', // Credits earned by register gift
PURCHASE = 'PURCHASE', // Credits earned by purchase
USAGE = 'USAGE', // Credits spent by usage
EXPIRE = 'EXPIRE', // Credits expired
MONTHLY_REFRESH = 'MONTHLY_REFRESH', // Credits earned by monthly refresh (free users)
REGISTER_GIFT = 'REGISTER_GIFT', // Credits earned by register gift
PURCHASE = 'PURCHASE', // Credits earned by purchase
SUBSCRIPTION_RENEWAL = 'SUBSCRIPTION_RENEWAL', // Credits earned by subscription renewal
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"),
amount: integer("amount").notNull(),
remainingAmount: integer("remaining_amount"),
paymentId: text("payment_id"), // payment_intent_id
paymentId: text("payment_id"),
expirationDate: timestamp("expiration_date"),
expirationDateProcessedAt: timestamp("expiration_date_processed_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),

View File

@ -1,5 +1,5 @@
import { randomUUID } from 'crypto';
import { addCredits } from '@/credits/credits';
import { addCredits, addSubscriptionRenewalCredits } from '@/credits/credits';
import { getCreditPackageByIdInServer } from '@/credits/server';
import { CREDIT_TRANSACTION_TYPE } from '@/credits/types';
import { getDb } from '@/db';
@ -608,6 +608,34 @@ export class StripeProvider implements PaymentProvider {
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
const updateFields: any = {
priceId: priceId,
@ -615,12 +643,8 @@ export class StripeProvider implements PaymentProvider {
status: this.mapSubscriptionStatusToPaymentStatus(
stripeSubscription.status
),
periodStart: stripeSubscription.current_period_start
? new Date(stripeSubscription.current_period_start * 1000)
: undefined,
periodEnd: stripeSubscription.current_period_end
? new Date(stripeSubscription.current_period_end * 1000)
: undefined,
periodStart: newPeriodStart,
periodEnd: newPeriodEnd,
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
trialStart: stripeSubscription.trial_start
? new Date(stripeSubscription.trial_start * 1000)
@ -631,7 +655,6 @@ export class StripeProvider implements PaymentProvider {
updatedAt: new Date(),
};
const db = await getDb();
const result = await db
.update(payment)
.set(updateFields)
@ -642,6 +665,24 @@ export class StripeProvider implements PaymentProvider {
console.log(
`<< 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 {
console.warn(
`<< 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
}
/**
* 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
*
@ -72,6 +81,7 @@ export interface PricePlan {
isLifetime: boolean; // Whether this is a lifetime plan
recommended?: boolean; // Whether to mark this plan as recommended 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
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
}