diff --git a/src/config/website.tsx b/src/config/website.tsx index 9ac824a..7f0dc32 100644 --- a/src/config/website.tsx +++ b/src/config/website.tsx @@ -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, diff --git a/src/credits/credits.ts b/src/credits/credits.ts index 9cde6c0..de04226 100644 --- a/src/credits/credits.ts +++ b/src/credits/credits.ts @@ -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}`); + } +} diff --git a/src/credits/types.ts b/src/credits/types.ts index 0f94a21..9641f54 100644 --- a/src/credits/types.ts +++ b/src/credits/types.ts @@ -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 } /** diff --git a/src/db/schema.ts b/src/db/schema.ts index fd56eeb..0f05063 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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(), diff --git a/src/payment/provider/stripe.ts b/src/payment/provider/stripe.ts index a6b22fc..e68636e 100644 --- a/src/payment/provider/stripe.ts +++ b/src/payment/provider/stripe.ts @@ -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}` diff --git a/src/payment/types.ts b/src/payment/types.ts index 05ca5bf..c0e9787 100644 --- a/src/payment/types.ts +++ b/src/payment/types.ts @@ -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 } /** diff --git a/src/types/index.d.ts b/src/types/index.d.ts index e8cf263..39fd6d3 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -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; // Packages indexed by ID }