diff --git a/apps/cdn/src/utils/quota-management.ts b/apps/cdn/src/utils/quota-management.ts index b64193d..fa79613 100644 --- a/apps/cdn/src/utils/quota-management.ts +++ b/apps/cdn/src/utils/quota-management.ts @@ -234,6 +234,7 @@ async function handleFreePlanUploadDeduction( // Refresh the FREE plan with new quota and immediately deduct 1 for this request // This ensures the refresh operation is immediately successful + // Note: projectNums is preserved to maintain user's actual project count await tx .update(subscriptionLimit) .set({ @@ -242,7 +243,7 @@ async function handleFreePlanUploadDeduction( uploadLimit: freePlanLimits.aiNums - 1, // Refresh and deduct in one operation deployLimit: freePlanLimits.aiNums * 2, seats: freePlanLimits.seats, - projectNums: freePlanLimits.projectNums, + // projectNums: keep existing value, don't reset periodStart: newPeriodStart.toISOString(), periodEnd: nextPeriodEnd.toISOString(), updatedAt: sql`CURRENT_TIMESTAMP`, diff --git a/packages/auth/utils/subscription-limits/core.ts b/packages/auth/utils/subscription-limits/core.ts index f74cd13..4b3061c 100644 --- a/packages/auth/utils/subscription-limits/core.ts +++ b/packages/auth/utils/subscription-limits/core.ts @@ -129,17 +129,28 @@ export async function createOrUpdateSubscriptionLimit( .then((rows) => rows[0]) if (existingActiveRecord) { - // Update existing active FREE plan + // Update existing active FREE plan - reset quotas to default values + // This ensures users get fresh quota when their FREE plan is refreshed + // Note: projectNums is NOT reset as it represents existing user projects await db .update(subscriptionLimit) .set({ + aiNums: limits.aiNums, + enhanceNums: limits.aiNums, + uploadLimit: limits.aiNums, + deployLimit: limits.aiNums * 2, + seats: limits.seats, + periodStart: utcPeriodStart.toISOString(), + periodEnd: utcPeriodEnd.toISOString(), updatedAt: sql`CURRENT_TIMESTAMP`, }) .where(eq(subscriptionLimit.id, existingActiveRecord.id)) - log.subscription('warn', 'Updated existing FREE plan', { + log.subscription('info', 'Updated FREE plan with quota reset', { organizationId, planName: PLAN_TYPES.FREE, + aiNums: limits.aiNums, + seats: limits.seats, operation: 'create_or_update_subscription_limit' }); } else { @@ -519,6 +530,7 @@ async function handleFreePlanDeduction( // Refresh the FREE plan with new quota and immediately deduct 1 for this request // This ensures the refresh operation is immediately successful + // Note: projectNums is preserved to maintain user's actual project count await tx .update(subscriptionLimit) .set({ @@ -527,10 +539,9 @@ async function handleFreePlanDeduction( uploadLimit: freePlanLimits.aiNums, deployLimit: freePlanLimits.aiNums * 2, seats: freePlanLimits.seats, - projectNums: freePlanLimits.projectNums, + // projectNums: keep existing value, don't reset periodStart: newPeriodStart.toISOString(), periodEnd: nextPeriodEnd.toISOString(), - updatedAt: sql`CURRENT_TIMESTAMP`, }) .where(eq(subscriptionLimit.id, freeLimit.id)) @@ -648,9 +659,89 @@ export async function getSubscriptionUsage(organizationId: string): Promise periodEndTimestamp) { + log.subscription('info', 'FREE plan expired during usage query, refreshing', { + organizationId, + periodEnd: freeLimit.periodEnd, + operation: 'getSubscriptionUsage' + }); + + // Get FREE plan default limits + const { limits: freePlanLimits } = await getPlanLimits(PLAN_TYPES.FREE) + + // Calculate new period + let newPeriodStart = new Date(freeLimit.periodStart) + while (addMonths(newPeriodStart, 1).getTime() <= nowTimestamp) { + newPeriodStart = addMonths(newPeriodStart, 1) + } + + // Align to UTC midnight + newPeriodStart = new Date( + Date.UTC( + newPeriodStart.getUTCFullYear(), + newPeriodStart.getUTCMonth(), + newPeriodStart.getUTCDate(), + 0, + 0, + 0, + 0 + ) + ) + + const nextPeriodEnd = addMonths(newPeriodStart, 1) + + // Refresh the FREE plan quota + // Note: updatedAt will be automatically updated by Drizzle's .$onUpdate() mechanism + await db + .update(subscriptionLimit) + .set({ + aiNums: freePlanLimits.aiNums, + enhanceNums: freePlanLimits.aiNums, + uploadLimit: freePlanLimits.aiNums, + deployLimit: freePlanLimits.aiNums * 2, + seats: freePlanLimits.seats, + // projectNums: keep existing value, don't reset + periodStart: newPeriodStart.toISOString(), + periodEnd: nextPeriodEnd.toISOString(), + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where(eq(subscriptionLimit.id, freeLimit.id)) + + // Update the local freeLimit object with refreshed values + freeLimit = { + ...freeLimit, + aiNums: freePlanLimits.aiNums, + enhanceNums: freePlanLimits.aiNums, + uploadLimit: freePlanLimits.aiNums, + deployLimit: freePlanLimits.aiNums * 2, + seats: freePlanLimits.seats, + periodStart: newPeriodStart.toISOString(), + periodEnd: nextPeriodEnd.toISOString(), + } + + log.subscription('info', 'FREE plan refreshed during usage query', { + organizationId, + newAiNums: freePlanLimits.aiNums, + newPeriodEnd: nextPeriodEnd.toISOString(), + operation: 'getSubscriptionUsage' + }); + } + } + const planNamesToQuery = new Set() if (freeLimit) planNamesToQuery.add(PLAN_TYPES.FREE) if (paidLimit) planNamesToQuery.add(paidLimit.planName) @@ -817,6 +908,7 @@ async function handleFreePlanEnhanceDeduction( // Refresh the FREE plan with new quota and immediately deduct 1 for this request // This ensures the refresh operation is immediately successful + // Note: projectNums is preserved to maintain user's actual project count await tx .update(subscriptionLimit) .set({ @@ -825,10 +917,9 @@ async function handleFreePlanEnhanceDeduction( uploadLimit: freePlanLimits.aiNums, deployLimit: freePlanLimits.aiNums * 2, seats: freePlanLimits.seats, - projectNums: freePlanLimits.projectNums, + // projectNums: keep existing value, don't reset periodStart: newPeriodStart.toISOString(), periodEnd: nextPeriodEnd.toISOString(), - updatedAt: sql`CURRENT_TIMESTAMP`, }) .where(eq(subscriptionLimit.id, freeLimit.id)) @@ -1036,6 +1127,7 @@ async function handleFreePlanProjectDeduction( // Refresh the FREE plan with new quota and immediately deduct 1 for this request // This ensures the refresh operation is immediately successful + // Note: projectNums represents remaining quota, so we refresh to default then deduct 1 await tx .update(subscriptionLimit) .set({ @@ -1044,16 +1136,15 @@ async function handleFreePlanProjectDeduction( uploadLimit: freePlanLimits.aiNums, deployLimit: freePlanLimits.aiNums * 2, seats: freePlanLimits.seats, - projectNums: freePlanLimits.projectNums - 1, // Refresh and deduction in one operation + projectNums: freePlanLimits.projectNums - 1, // Refresh quota and deduct 1 for current project creation periodStart: newPeriodStart.toISOString(), periodEnd: nextPeriodEnd.toISOString(), - updatedAt: sql`CURRENT_TIMESTAMP`, }) .where(eq(subscriptionLimit.id, freeLimit.id)) log.subscription('info', 'FREE plan refreshed and project deducted', { organizationId, - remaining: freePlanLimits.projectNums - 1, + newProjectNums: freePlanLimits.projectNums - 1, operation: 'project_usage_deduction' }) return true // Refresh and deduction completed successfully