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: [],
|
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,
|
||||||
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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(),
|
||||||
|
@ -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}`
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
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
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user