feat: enhance credits management with subscription renewal and lifetime monthly credits
This commit is contained in:
parent
74d7cf44a1
commit
737bd7f80f
@ -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,
|
||||
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
@ -2,9 +2,11 @@
|
||||
* Credit transaction type enum
|
||||
*/
|
||||
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
|
||||
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
|
||||
}
|
||||
|
@ -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(),
|
||||
|
@ -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}`
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
5
src/types/index.d.ts
vendored
5
src/types/index.d.ts
vendored
@ -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
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user