How to Implement Annual Subscriptions?
Good to know
- This document is based on v1.2.0 code. If you're using a newer version, you can reference the steps but don't use the code in this document.
- Starting from v2.0.0, Nexty has built-in logic for what this article implements, and it's more comprehensive. You can follow the steps and implementation methods described in this article to study by comparing with the latest code, or refer to the AI Image Generation Website Boilerplate implementation for comparison and learning. The latter has a better design for displaying user benefits that's more suitable for credit-based products.
Nexty.dev is a fully-featured SaaS boilerplate that includes monthly subscription logic by default. However, in real business scenarios, users often want to provide annual subscription functionality while maintaining monthly credit allocation. This model provides users with annual payment discounts while ensuring reasonable credit allocation and usage.
This document will explain in detail how to implement annual subscription mode based on the existing Nexty.dev boilerplate.
Current Architecture Review
Database Design
The current system includes the following core tables:
-
pricing_plans
- Pricing plans tablebenefits_jsonb
- Benefits configuration (e.g.,{"monthly_credits": 100}
)recurring_interval
- Billing cycle (month/year)payment_type
- Payment type (recurring/one_time)
-
subscriptions
- Subscription status table- Tracks Stripe subscription status
- Records billing cycle times
-
orders
- Order records table- Records all payment events
- Supports multiple order types
-
usage
- Usage management tablesubscription_credits_balance
- Subscription credit balanceone_time_credits_balance
- One-time credit balancebalance_jsonb
- Extended balance field
Payment Logic
The existing system already supports:
- Monthly subscriptions: Automatically charge monthly and reset credits
- One-time purchases: Immediately grant credits
- Stripe webhook handling for various payment events
Annual Subscription Design
Core Requirements
- Users pay the full annual fee at once
- System immediately allocates first month's credits when subscription starts
- Automatically allocate next month's credits each subsequent month until 12 months complete
- Support credit recovery when subscription is cancelled
Implementation Approach
Based on Nexty.dev's existing functionality and logic, we can outline the following implementation steps:
- Create annual pricing plans, recording necessary information for annual subscriptions in
benefits_jsonb
- Extend the
usage
table to record annual subscription information and support monthly credit distribution - Implement annual subscription benefit upgrades and refund benefit recovery in webhook-related methods without affecting monthly subscription logic
- Users can see annual subscription information
- Successfully update monthly credits. There are two implementation approaches:
- Use scheduled tasks to find data that needs redistribution daily and reset credits
- When querying user benefits, if the current month has ended, reset credits. This approach is lighter weight, and we'll use this solution in this document
Implementation Steps
Step 1: Create New Pricing
First, create a new product pricing in Stripe, selecting annual payment

Then copy the Price ID

Start your project based on Nexty.dev development, go to /dashboard/prices
, and begin creating a new pricing plan

Paste the previously copied Price ID, then get pricing information. You can see the retrieved annual pricing


In the Benefits JSON field, enter your designed annual pricing information, which will be used for all subsequent processes

For example, in our case we input:
{
"total_months": 12,
"credits_per_month": 500
}
Step 2: Design usage
Table Recording Method
For different benefit designs, we can choose to extend usage
table fields or use the existing balance_jsonb
field to record benefits.
Since we want to implement annual subscriptions with monthly credit distribution, we can use the existing subscription_credits_balance
field to record current month's remaining credits, then use balance_jsonb
to record annual information for related functionality to determine this is an annual subscription.
After analysis, we can design the following structure to record annual information:
{
"yearly_allocation_details": {
"remaining_months": 11,
"next_credit_date": "2025-06-03T00:00:00.000Z",
"monthly_credit_amount": 500,
"last_allocated_month": "2025-06"
}
}
Their meanings are:
yearly_allocation_details
: This is chosen to wrap annual information with a separate key, ensuring no mutual interference if users purchase multiple pricing plans simultaneouslyremaining_months
: Records how many months of the annual subscription haven't distributed creditsnext_credit_date
: Records the date for next credit distributionmonthly_credit_amount
: Records how many credits to distribute monthly, from thecredits_per_month
we set in the pricing planlast_allocated_month
: The last month when credits were successfully allocated
To make yearly_allocation_details
work correctly, we need to design an annual subscription benefit initialization RPC and a monthly benefit update RPC for annual subscriptions, named initialize_or_reset_yearly_allocation
and allocate_specific_monthly_credit_for_year_plan
respectively.
Also, to synchronize the benefit acquisition logic for monthly and annual subscriptions, I've optimized the definitions of upsert_and_set_subscription_credits
and revoke_credits
functions.
The function definitions are too long to paste here. Please check the data/5、usage(yearly_credits_rpc).sql
file in the branch code, then run the complete SQL statements in the file in Supabase's SQL Editor. After execution, run supabase gen types typescript --project-id <your-project-id> --schema public > lib/supabase/types.ts
locally to update type definitions.
Step 3: Upgrade Annual Subscription Benefits
The subscription payment processing entry point is handleInvoicePaid
in lib/stripe/webhook-handlers.ts
. Locate upgradeSubscriptionCredits
, which is where custom subscription user benefit upgrades happen.
In earlier source code versions, the code was like this:
if (planId && userId) {
// --- [custom] Upgrade the user's benefits ---
upgradeSubscriptionCredits(userId, planId, invoiceId);
// --- End: [custom] Upgrade the user's benefits ---
}
Now we want to display more subscription information on the user side, so we need to pass subscription
to upgradeSubscriptionCredits
:
if (planId && userId && subscription) {
// --- [custom] Upgrade the user's benefits ---
upgradeSubscriptionCredits(userId, planId, invoiceId, subscription);
// --- End: [custom] Upgrade the user's benefits ---
}
export async function upgradeSubscriptionCredits(userId: string, planId: string, invoiceId: string, subscription: Stripe.Subscription) {
const supabaseAdmin = ...
try {
// Need to get `recurring_interval` to determine if it's monthly or annual subscription
const { data: planData, error: planError } = await supabaseAdmin
.from('pricing_plans')
// .select('benefits_jsonb') // remove
.select('recurring_interval, benefits_jsonb') // add
.eq('id', planId)
.single();
if (planError || !planData) {
// ...
} else {
// This logic needs modification. First determine if it's monthly or annual subscription, then handle separately. This minimizes impact on existing logic
const benefits = planData.benefits_jsonb as any;
const recurringInterval = planData.recurring_interval;
// Monthly subscription logic remains unchanged
if (recurringInterval === 'month' && benefits?.monthly_credits) {
const creditsToGrant = benefits.monthly_credits;
const { error: usageError } = await supabaseAdmin.rpc('upsert_and_set_subscription_credits', {
p_user_id: userId,
p_credits_to_set: creditsToGrant
});
// ...
return // Remember to return so program doesn't need next judgment
}
// New: Annual subscription logic
if (recurringInterval === 'year' && benefits?.total_months && benefits?.credits_per_month) {
await supabaseAdmin.rpc('initialize_or_reset_yearly_allocation', {
p_user_id: userId,
p_total_months: benefits.total_months,
p_credits_per_month: benefits.credits_per_month,
p_subscription_start_date: new Date(subscription.start_date * 1000).toISOString()
});
return
}
}
} catch (creditError) {
// ...
}
}
Now you can test the payment process on your page. After completion, check the newly added data in the orders
, subscriptions
, and usage
tables in your Supabase database to confirm it matches your design.
Step 4: Update User Benefit Display
This step requires extending the original monthly subscription benefit acquisition logic to add annual subscription acquisition and update logic.
To display more detailed user subscription information on the frontend, we need to extend the UserBenefits type definition:
export interface UserBenefits {
activePlanId: string | null;
subscriptionStatus: string | null;
currentPeriodEnd: string | null; // add
nextCreditDate: string | null; // add, monthly is null, yearly is the next credit date
totalAvailableCredits: number;
subscriptionCreditsBalance: number;
oneTimeCreditsBalance: number;
}
Also define default return data:
const defaultUserBenefits: UserBenefits = {
activePlanId: null,
subscriptionStatus: null,
currentPeriodEnd: null,
nextCreditDate: null,
totalAvailableCredits: 0,
subscriptionCreditsBalance: 0,
oneTimeCreditsBalance: 0,
};
// `getUserBenefits` method code is too long, document only shows approach, see source code for complete code
export async function getUserBenefits(userId: string): Promise<UserBenefits> {
if (!userId) {
return defaultUserBenefits;
}
// For users to get their own data, limited by RLS policy
const supabase = await createClient();
// Use admin privileges to call RPC
const supabaseAdminClient = createAdminClient(
// ...
);
try {
// 1. Get user's current usage data, including balance_jsonb, which may contain annual subscription allocation details
let { data: usageData, error: usageError } = await supabase
.from('usage')
// .select('subscription_credits_balance, one_time_credits_balance') // remove
.select('subscription_credits_balance, one_time_credits_balance, balance_jsonb') // add
.eq('user_id', userId)
.maybeSingle();
// ...
// --- Start of Yearly Subscription Catch-up Logic ---
let currentBalanceJsonb = usageData.balance_jsonb as any;
let currentYearlyDetails = currentBalanceJsonb?.yearly_allocation_details;
// 2. Loop check and allocate
while (
currentYearlyDetails && ...
) {
const creditsToAllocate = currentYearlyDetails.credits_per_month;
const yearMonthToAllocate = new Date(currentYearlyDetails.next_credit_date).toISOString().slice(0, 7);
const { error: rpcError } = await supabaseAdminClient.rpc('allocate_specific_monthly_credit_for_year_plan', {
// ...
});
if (rpcError) {
// ...
break;
} else {
// Re-fetch usage data to get the latest state after allocation
const { data: updatedUsageData, error: refetchError } = await supabase
.from('usage')
.select('subscription_credits_balance, one_time_credits_balance, balance_jsonb')
.eq('user_id', userId)
.maybeSingle();
// ...
usageData = updatedUsageData;
currentBalanceJsonb = usageData.balance_jsonb as any;
currentYearlyDetails = currentBalanceJsonb?.yearly_allocation_details;
// ...
}
}
// --- End of Yearly Subscription Catch-up Logic ---
if (!usageData) {
return defaultUserBenefits;
}
const subCredits = usageData?.subscription_credits_balance ?? 0;
const oneTimeCredits = usageData?.one_time_credits_balance ?? 0;
const totalCredits = subCredits + oneTimeCredits;
const { data: subscription, error: subscriptionError } = await supabase
.from('subscriptions')
// .select('plan_id, status, current_period_end') // remove
.select('plan_id, status, current_period_end, cancel_at_period_end') // add
.eq('user_id', userId)
// .in('status', ['active', 'trialing']) // remove
.order('created_at', { ascending: false })
.limit(1)
// .maybeSingle(); // remove
.single();
// ...
return {
activePlanId,
subscriptionStatus,
currentPeriodEnd,
nextCreditDate,
totalAvailableCredits: totalCredits,
subscriptionCreditsBalance: subCredits,
oneTimeCreditsBalance: oneTimeCredits,
};
} catch (error) {
// ...
return defaultUserBenefits;
}
}
Good to know
The
while(){}
code block designs a subscription catch-up solution, so even if users don't log in or use membership benefits for several months, they can still track accurate monthly periods when they log back in. Scenario example:
- User becomes an annual member in June, at which point the
usage
table records that the next credit redistribution month is July- But the user doesn't log in in July, and only logs back in to use the product in August
- At this point there's a judgment error because the
usage
table records July, and the program will automatically catch up to the accurate August cycle
Next, update the frontend user benefit display.
The user benefit display solution needs to be customized based on business requirements. The display method provided here is just an example:
// ...
return (
<div className="flex flex-col gap-2 text-sm">
<div className="flex items-center gap-2">
<Coins className="w-4 h-4 text-primary" />
<span>Credits: {benefits.totalAvailableCredits}</span>
</div>
{benefits.nextCreditDate && (
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-primary" />
<span>
Update Date: {dayjs(benefits.nextCreditDate).format("YYYY-MM-DD")}
</span>
</div>
)}
{benefits.currentPeriodEnd && (
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-primary" />
<span>
Period End:{" "}
{dayjs(benefits.currentPeriodEnd).format("YYYY-MM-DD")}
</span>
</div>
)}
</div>
)
// ...
On the /dashboard/subscription
page, we also need to optimize the subscription user judgment:
// const isMember = benefits.subscriptionStatus === "active" || benefits.subscriptionStatus === "trialing"; // remove
const isMember = benefits.subscriptionStatus; // add
Now you can see the latest subscription benefit information in the Header's top right corner and on the /dashboard/subscription
page, and you can test credit usage on the /dashboard/credit-usage-example
page.
Step 5: Refund Credit Recovery
The subscription refund processing entry point is revokeSubscriptionCredits
in lib/stripe/webhook-handlers.ts
.
export async function revokeSubscriptionCredits(userId: string, planId: string, subscriptionId: string) {
const supabaseAdmin = createAdminClient(
// ...
);
// --- [custom] Revoke the user's subscription benefits ---
try {
const { data: planData, error: planError } = await supabaseAdmin
.from('pricing_plans')
// .select('benefits_jsonb') // remove
.select('recurring_interval') // add
.eq('id', planId)
.single();
if (planError || !planData) {
return;
}
let subscriptionToRevoke = 0;
const recurringInterval = planData.recurring_interval;
let clearYearly = false; // Used by RPC to determine whether to clear annual subscription information
let clearMonthly = false; // Used by RPC to determine whether to clear monthly subscription information
const { data: usageData, error: usageError } = await supabaseAdmin
.from('usage')
.select('balance_jsonb')
.eq('user_id', userId)
.single();
// ...
// Handle monthly and annual subscription information separately
if (recurringInterval === 'year') {
const yearlyDetails = usageData.balance_jsonb?.yearly_allocation_details;
subscriptionToRevoke = yearlyDetails?.credits_per_month
clearYearly = true;
} else if (recurringInterval === 'month') {
const monthlyDetails = usageData.balance_jsonb?.monthly_allocation_details;
subscriptionToRevoke = monthlyDetails?.credits_per_month
clearMonthly = true;
}
if (subscriptionToRevoke) {
const { data: revokeResult, error: revokeError } = await supabaseAdmin.rpc('revoke_credits', {
// ...
});
}
} catch (error) {
console.error(`Error during revokeSubscriptionCredits for user ${userId}, subscription ${subscriptionId}:`, error);
}
// --- End: [custom] Revoke the user's subscription benefits ---
}
This completes all development work for annual subscriptions with monthly credit distribution.
Conclusion
Nexty.dev's pricing and payment functionality leads other Next.js SaaS boilerplates in completeness, security, and flexibility. However, since different products have vastly different subscription benefit designs, we cannot preset all personalized needs into the boilerplate.
The Nexty.dev boilerplate aims to help boilerplate users more easily implement their required payment functionality through visual management (admin dashboard for creating and editing pricing), complete payment processes, (webhook event handling), controllable customization steps (code blocks marked with [custom]), and documentation explaining implementation approaches.