Credit System
The credit system is one of the core features of NEXTY.DEV's payment system. It manages credits that users obtain through purchasing plans and tracks credit usage. This document details the design and usage of the credit system.
Credit System Overview
Credit Types
The system supports two types of credits:
- One-time Credits
- Obtained through one-time payments
- Do not expire
- Decrease after use and are not automatically replenished
- Subscription Credits
- Obtained through subscription plans
- Allocated monthly or yearly
- Periodically reset or replenished
Credit Balance Storage
Credit balances are stored in the usage table:
{
userId: string; // User ID (unique)
subscriptionCreditsBalance: number; // Subscription credits balance
oneTimeCreditsBalance: number; // One-time credits balance
balanceJsonb: { // Balance details (JSONB)
monthlyAllocationDetails?: { // Monthly subscription details
monthlyCredits: number;
relatedOrderId: string;
};
yearlyAllocationDetails?: { // Yearly subscription details
remainingMonths: number;
nextCreditDate: string;
monthlyCredits: number;
lastAllocatedMonth: string;
relatedOrderId: string;
};
};
}Credit Logs
All credit changes are recorded in the credit_logs table:
{
userId: string;
amount: number; // Change amount (positive for increase, negative for decrease)
oneTimeBalanceAfter: number; // One-time credits balance after change
subscriptionBalanceAfter: number; // Subscription credits balance after change
type: string; // Change type
notes: string; // Notes
relatedOrderId?: string; // Related order ID
createdAt: Date; // Creation time
}Credit Granting
One-time Credit Granting
When users purchase a one-time payment plan, the system grants one-time credits:
export async function upgradeOneTimeCredits(
userId: string,
planId: string,
orderId: string
) {
// 1. Get plan benefits configuration
const plan = await db
.select({ benefitsJsonb: pricingPlansSchema.benefitsJsonb })
.from(pricingPlansSchema)
.where(eq(pricingPlansSchema.id, planId))
.limit(1);
const creditsToGrant = plan.benefitsJsonb?.oneTimeCredits || 0;
if (creditsToGrant > 0) {
// 2. Update credit balance using transaction
await db.transaction(async (tx) => {
// Update or insert credit balance
const updatedUsage = await tx
.insert(usageSchema)
.values({
userId,
oneTimeCreditsBalance: creditsToGrant,
})
.onConflictDoUpdate({
target: usageSchema.userId,
set: {
oneTimeCreditsBalance: sql`${usageSchema.oneTimeCreditsBalance} + ${creditsToGrant}`,
},
})
.returning({
oneTimeBalanceAfter: usageSchema.oneTimeCreditsBalance,
subscriptionBalanceAfter: usageSchema.subscriptionCreditsBalance,
});
// 3. Record credit log
await tx.insert(creditLogsSchema).values({
userId,
amount: creditsToGrant,
oneTimeBalanceAfter: updatedUsage[0].oneTimeBalanceAfter,
subscriptionBalanceAfter: updatedUsage[0].subscriptionBalanceAfter,
type: 'one_time_purchase',
notes: 'One-time credit purchase',
relatedOrderId: orderId,
});
});
}
}Subscription Credit Granting
Monthly Subscription
Monthly subscriptions reset credits on each renewal:
export async function upgradeSubscriptionCredits(
userId: string,
planId: string,
orderId: string,
currentPeriodStart: number
) {
const plan = await db
.select({
recurringInterval: pricingPlansSchema.recurringInterval,
benefitsJsonb: pricingPlansSchema.benefitsJsonb
})
.from(pricingPlansSchema)
.where(eq(pricingPlansSchema.id, planId))
.limit(1);
const monthlyCredits = plan.benefitsJsonb?.monthlyCredits || 0;
if (isMonthlyInterval(plan.recurringInterval) && monthlyCredits > 0) {
await db.transaction(async (tx) => {
// Update subscription credits balance (reset to monthly credits)
const updatedUsage = await tx
.insert(usageSchema)
.values({
userId,
subscriptionCreditsBalance: monthlyCredits,
balanceJsonb: {
monthlyAllocationDetails: {
monthlyCredits,
relatedOrderId: orderId,
}
},
})
.onConflictDoUpdate({
target: usageSchema.userId,
set: {
subscriptionCreditsBalance: monthlyCredits,
balanceJsonb: sql`coalesce(${usageSchema.balanceJsonb}, '{}'::jsonb) - 'monthlyAllocationDetails' || ${JSON.stringify(monthlyDetails)}::jsonb`,
},
})
.returning({
oneTimeBalanceAfter: usageSchema.oneTimeCreditsBalance,
subscriptionBalanceAfter: usageSchema.subscriptionCreditsBalance,
});
// Record credit log
await tx.insert(creditLogsSchema).values({
userId,
amount: monthlyCredits,
oneTimeBalanceAfter: updatedUsage[0].oneTimeBalanceAfter,
subscriptionBalanceAfter: updatedUsage[0].subscriptionBalanceAfter,
type: 'subscription_grant',
notes: 'Subscription credits granted/reset',
relatedOrderId: orderId,
});
});
}
}Yearly Subscription
Yearly subscriptions use a monthly allocation approach:
if (isYearlyInterval(plan.recurringInterval) && benefits?.totalMonths && benefits?.monthlyCredits) {
await db.transaction(async (tx) => {
const startDate = new Date(currentPeriodStart);
const nextCreditDate = new Date(
startDate.getFullYear(),
startDate.getMonth() + 1,
startDate.getDate()
);
const yearlyDetails = {
yearlyAllocationDetails: {
remainingMonths: benefits.totalMonths - 1,
nextCreditDate: nextCreditDate.toISOString(),
monthlyCredits: benefits.monthlyCredits,
lastAllocatedMonth: `${startDate.getFullYear()}-${(startDate.getMonth() + 1).toString().padStart(2, '0')}`,
relatedOrderId: orderId,
}
};
// Initialize yearly subscription, immediately allocate first month's credits
const updatedUsage = await tx
.insert(usageSchema)
.values({
userId,
subscriptionCreditsBalance: benefits.monthlyCredits,
balanceJsonb: yearlyDetails,
})
.onConflictDoUpdate({
target: usageSchema.userId,
set: {
subscriptionCreditsBalance: benefits.monthlyCredits,
balanceJsonb: sql`coalesce(${usageSchema.balanceJsonb}, '{}'::jsonb) - 'yearlyAllocationDetails' || ${JSON.stringify(yearlyDetails)}::jsonb`,
}
})
.returning({
oneTimeBalanceAfter: usageSchema.oneTimeCreditsBalance,
subscriptionBalanceAfter: usageSchema.subscriptionCreditsBalance,
});
// Record credit log
await tx.insert(creditLogsSchema).values({
userId,
amount: benefits.monthlyCredits,
oneTimeBalanceAfter: updatedUsage[0].oneTimeBalanceAfter,
subscriptionBalanceAfter: updatedUsage[0].subscriptionBalanceAfter,
type: 'subscription_grant',
notes: 'Yearly plan initial credits granted',
relatedOrderId: orderId,
});
});
}Yearly Subscription Monthly Allocation
Yearly subscriptions use a "monthly allocation" strategy. The system automatically allocates credits each month:
async function processYearlySubscriptionCatchUp(userId: string) {
let shouldContinue = true;
while (shouldContinue) {
shouldContinue = await db.transaction(async (tx) => {
const usage = await tx
.select()
.from(usageSchema)
.where(eq(usageSchema.userId, userId))
.for('update');
const yearlyDetails = usage[0]?.balanceJsonb?.yearlyAllocationDetails;
// Check if credits need to be allocated
if (
!yearlyDetails ||
yearlyDetails.remainingMonths <= 0 ||
!yearlyDetails.nextCreditDate ||
new Date() < new Date(yearlyDetails.nextCreditDate)
) {
return false; // No allocation needed
}
// Allocate this month's credits
const creditsToAllocate = yearlyDetails.monthlyCredits;
const newRemainingMonths = yearlyDetails.remainingMonths - 1;
const nextCreditDate = new Date(yearlyDetails.nextCreditDate);
nextCreditDate.setMonth(nextCreditDate.getMonth() + 1);
// Update credit balance
await tx.update(usageSchema)
.set({
subscriptionCreditsBalance: creditsToAllocate,
balanceJsonb: {
...usage[0].balanceJsonb,
yearlyAllocationDetails: {
...yearlyDetails,
remainingMonths: newRemainingMonths,
nextCreditDate: nextCreditDate.toISOString(),
lastAllocatedMonth: new Date().toISOString().slice(0, 7),
}
},
})
.where(eq(usageSchema.userId, userId));
// Record credit log
await tx.insert(creditLogsSchema).values({
userId,
amount: creditsToAllocate,
type: 'subscription_grant',
notes: 'Yearly subscription monthly credits allocated',
relatedOrderId: yearlyDetails.relatedOrderId,
createdAt: new Date(yearlyDetails.nextCreditDate), // Use allocation date
});
return newRemainingMonths > 0; // Continue loop if there are remaining months
});
}
}Yearly subscription monthly credit reset does not use scheduled tasks. Instead, it's designed to automatically check and update each time user benefits are queried. This approach significantly reduces database requests and improves performance compared to scheduled task processing.
Credit Usage
Deducting Credits
When users consume credits by using features, call the deductCredits function:
export async function deductCredits(
amountToDeduct: number,
notes: string
): Promise<ActionResult<DeductCreditsData | null>> {
const session = await getSession();
const user = session?.user;
if (!user) return actionResponse.unauthorized();
if (amountToDeduct <= 0) {
return actionResponse.badRequest('Amount to deduct must be positive.');
}
try {
await db.transaction(async (tx) => {
// Lock user credit record
const usage = await tx
.select({
oneTimeCreditsBalance: usageSchema.oneTimeCreditsBalance,
subscriptionCreditsBalance: usageSchema.subscriptionCreditsBalance,
})
.from(usageSchema)
.where(eq(usageSchema.userId, user.id))
.for('update');
if (!usage[0]) {
throw new Error('INSUFFICIENT_CREDITS');
}
const totalCredits = usage[0].oneTimeCreditsBalance + usage[0].subscriptionCreditsBalance;
if (totalCredits < amountToDeduct) {
throw new Error('INSUFFICIENT_CREDITS');
}
// Prioritize deducting subscription credits, then one-time credits
const deductedFromSub = Math.min(usage[0].subscriptionCreditsBalance, amountToDeduct);
const deductedFromOneTime = amountToDeduct - deductedFromSub;
const newSubBalance = usage[0].subscriptionCreditsBalance - deductedFromSub;
const newOneTimeBalance = usage[0].oneTimeCreditsBalance - deductedFromOneTime;
// Update credit balance
await tx.update(usageSchema)
.set({
subscriptionCreditsBalance: newSubBalance,
oneTimeCreditsBalance: newOneTimeBalance,
})
.where(eq(usageSchema.userId, user.id));
// Record credit log
await tx.insert(creditLogsSchema)
.values({
userId: user.id,
amount: -amountToDeduct,
oneTimeBalanceAfter: newOneTimeBalance,
subscriptionBalanceAfter: newSubBalance,
type: 'feature_usage',
notes: notes,
});
});
// Return updated user benefits
const updatedBenefits = await getUserBenefits(user.id);
return actionResponse.success({
message: 'Credits deducted successfully.',
updatedBenefits,
});
} catch (e: any) {
if (e.message === 'INSUFFICIENT_CREDITS') {
return actionResponse.badRequest('Insufficient credits.');
}
return actionResponse.error(e.message || 'An unexpected server error occurred.');
}
}Deduction Strategy
The system uses a "prioritize subscription credits" strategy:
- First deduct subscription credits balance
- If subscription credits are insufficient, then deduct one-time credits
- Ensure both types of credits are fully utilized
Credit Revocation
One-time Credit Revocation
When users request refunds, revoke corresponding one-time credits:
export async function revokeOneTimeCredits(
refundAmountCents: number,
originalOrder: Order,
refundOrderId: string
) {
const planId = originalOrder.planId;
const userId = originalOrder.userId;
// Only process full refunds
const isFullRefund = refundAmountCents === Math.round(parseFloat(originalOrder.amountTotal!) * 100);
if (isFullRefund) {
const plan = await db
.select({ benefitsJsonb: pricingPlansSchema.benefitsJsonb })
.from(pricingPlansSchema)
.where(eq(pricingPlansSchema.id, planId))
.limit(1);
const oneTimeToRevoke = plan.benefitsJsonb?.oneTimeCredits || 0;
if (oneTimeToRevoke > 0) {
await db.transaction(async (tx) => {
const usage = await tx
.select()
.from(usageSchema)
.where(eq(usageSchema.userId, userId))
.for('update');
if (!usage[0]) return;
const newOneTimeBalance = Math.max(0, usage[0].oneTimeCreditsBalance - oneTimeToRevoke);
const amountRevoked = usage[0].oneTimeCreditsBalance - newOneTimeBalance;
if (amountRevoked > 0) {
await tx.update(usageSchema)
.set({ oneTimeCreditsBalance: newOneTimeBalance })
.where(eq(usageSchema.userId, userId));
await tx.insert(creditLogsSchema).values({
userId,
amount: -amountRevoked,
oneTimeBalanceAfter: newOneTimeBalance,
subscriptionBalanceAfter: usage[0].subscriptionCreditsBalance,
type: 'refund_revoke',
notes: `Full refund for order ${originalOrder.id}.`,
relatedOrderId: originalOrder.id,
});
}
});
}
}
}Subscription Credit Revocation
When subscriptions are refunded or canceled, revoke subscription credits:
export async function revokeSubscriptionCredits(originalOrder: Order) {
const planId = originalOrder.planId;
const userId = originalOrder.userId;
const ctx = await getSubscriptionRevokeContext(planId, userId);
if (!ctx) return;
if (ctx.subscriptionToRevoke > 0) {
await applySubscriptionCreditsRevocation({
userId,
amountToRevoke: ctx.subscriptionToRevoke,
clearMonthly: ctx.clearMonthly,
clearYearly: ctx.clearYearly,
logType: 'refund_revoke',
notes: `Full refund for subscription order ${originalOrder.id}.`,
relatedOrderId: originalOrder.id,
});
}
}Credit Revocation on Subscription End
When subscriptions expire or are canceled, revoke remaining subscription credits:
export async function revokeRemainingSubscriptionCreditsOnEnd(
provider: PaymentProvider,
subscriptionId: string,
userId: string,
metadata: any
) {
const usage = await db
.select({ subscriptionCreditsBalance: usageSchema.subscriptionCreditsBalance })
.from(usageSchema)
.where(eq(usageSchema.userId, userId))
.limit(1);
const amountToRevoke = usage[0]?.subscriptionCreditsBalance ?? 0;
if (amountToRevoke > 0) {
await applySubscriptionCreditsRevocation({
userId,
amountToRevoke,
clearMonthly: true,
clearYearly: true,
logType: 'subscription_ended_revoke',
notes: `${provider} subscription ${subscriptionId} ended; remaining credits revoked.`,
relatedOrderId: null,
});
}
}Querying User Benefits
Get User Benefits
Use the getUserBenefits function to query a user's complete benefits information:
export async function getUserBenefits(userId: string): Promise<UserBenefits> {
// 1. Query credit balance
const usage = await db
.select({
subscriptionCreditsBalance: usageSchema.subscriptionCreditsBalance,
oneTimeCreditsBalance: usageSchema.oneTimeCreditsBalance,
balanceJsonb: usageSchema.balanceJsonb,
})
.from(usageSchema)
.where(eq(usageSchema.userId, userId));
// 2. Process yearly subscription monthly allocation
if (usage[0]) {
await processYearlySubscriptionCatchUp(userId);
}
// 3. Query subscription information
const subscription = await fetchSubscriptionData(userId);
// 4. Build benefits object
return {
activePlanId: subscription?.status === 'active' ? subscription.planId : null,
subscriptionStatus: subscription?.status,
currentPeriodEnd: subscription?.currentPeriodEnd,
nextCreditDate: usage[0]?.balanceJsonb?.yearlyAllocationDetails?.nextCreditDate,
totalAvailableCredits: (usage[0]?.subscriptionCreditsBalance || 0) + (usage[0]?.oneTimeCreditsBalance || 0),
subscriptionCreditsBalance: usage[0]?.subscriptionCreditsBalance || 0,
oneTimeCreditsBalance: usage[0]?.oneTimeCreditsBalance || 0,
};
}Client-side Query
Frontend can use the getClientUserBenefits function:
export async function getClientUserBenefits(): Promise<ActionResult<UserBenefits>> {
const session = await getSession();
const user = session?.user;
if (!user) return actionResponse.unauthorized();
try {
const benefits = await getUserBenefits(user.id);
return actionResponse.success(benefits);
} catch (error: any) {
return actionResponse.error(error.message || 'Failed to fetch user benefits.');
}
}Credit Log Query
Query User Credit History
export async function getCreditLogs({
pageIndex = 0,
pageSize = 20,
}: ListCreditLogsParams = {}): Promise<ListCreditLogsResult> {
const session = await getSession();
const user = session?.user;
if (!user) return actionResponse.unauthorized();
const logs = await db
.select()
.from(creditLogsSchema)
.where(eq(creditLogsSchema.userId, user.id))
.orderBy(desc(creditLogsSchema.createdAt))
.offset(pageIndex * pageSize)
.limit(pageSize);
const totalCount = await db
.select({ value: count() })
.from(creditLogsSchema)
.where(eq(creditLogsSchema.userId, user.id));
return actionResponse.success({
logs: logs || [],
count: totalCount[0]?.value ?? 0
});
}Credit Change Types
The system supports the following credit change types:
one_time_purchase: Credits obtained from one-time purchasesubscription_grant: Credits granted from subscription (monthly reset or yearly monthly allocation)feature_usage: Credits consumed from feature usagerefund_revoke: Credits revoked from refundsubscription_ended_revoke: Remaining credits revoked when subscription ends
Frequently Asked Questions
Q: How are credits allocated for yearly subscriptions?
A: Yearly subscriptions use a "monthly allocation" strategy:
- Immediately allocate the first month's credits when subscribing
- Each month thereafter, when users query credits, if the allocation time is reached, allocate credits once
- The allocation logic automatically executes in the
getUserBenefitsfunction
Q: If a user has both subscription credits and one-time credits, which is prioritized when deducting?
A: The system prioritizes deducting subscription credits, then one-time credits. This ensures subscription credits are fully utilized.
Q: Will remaining subscription credits be revoked after subscription cancellation?
A: Yes, when a subscription expires or is canceled, the system automatically revokes remaining subscription credits.
Q: How to query a user's credit history?
A: Use the getCreditLogs function to query a user's credit change history, with pagination support.