How to Implement Yearly Subscriptions?
Nexty.dev is a feature-complete SaaS template that includes monthly subscription logic by default. However, in real business scenarios, users often want to provide yearly subscription functionality while maintaining monthly credit allocation. This model offers users yearly discounts while ensuring reasonable credit distribution and usage.
This document will explain in detail how to implement yearly subscription mode based on the existing Nexty.dev template.
The implementation described in this document is currently only available on the subscription-yearly branch. Whether it will be merged into the main branch depends on future user demand.
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 timing
-
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: Automatic monthly billing and credit reset
- One-time purchases: Immediate credit allocation
- Stripe webhook processing for various payment events
Yearly Subscription Design
Core Requirements
- Users pay the full year amount in one transaction
- System immediately allocates the first month's credits when subscription starts
- Automatically allocate next month's credits monthly until 12 months are complete
- Support credit recovery when subscription is canceled
Implementation Approach
Based on Nexty.dev's existing functionality and logic, we can outline the following implementation steps:
- Create yearly pricing cards, recording necessary yearly subscription information in
benefits_jsonb
- Extend the
usage
table to record yearly subscription information and support monthly credit allocation - Implement yearly subscription benefit upgrades and refund benefit recovery in webhook-related methods without affecting monthly subscription logic
- Allow users to view yearly subscription information
- Successfully update monthly credits. There are two implementation approaches:
- Use scheduled tasks to find data that needs reallocation daily and reset credits
- When querying user benefits, if the current month has ended, reset credits. This approach is more lightweight, and we'll use this method in this document
Implementation Steps
Step 1: Create New Pricing
First, create a new product pricing in Stripe, selecting yearly payment
Then copy the Price ID
Start your project based on Nexty.dev development, go to /dashboard/prices
, and begin creating a new pricing card
Paste the previously copied Price ID, then get pricing information. You can see the retrieved yearly pricing
In the Benefits JSON field, enter your designed yearly pricing information, which will be used in 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 yearly subscription with monthly credit allocation, we can use the existing subscription_credits_balance
field to record current month's remaining credits, then use balance_jsonb
to record yearly information for functional judgment that this is a yearly subscription.
After analysis, we can design the following structure to record yearly 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
: Using a separate key to wrap yearly information ensures no interference if users purchase multiple pricing plans simultaneouslyremaining_months
: Records how many months of yearly subscription remain unallocatednext_credit_date
: Records the next credit allocation datemonthly_credit_amount
: Records monthly credit allocation amount, from thecredits_per_month
in the pricing cardlast_allocated_month
: The last month when credits were successfully allocated
To make yearly_allocation_details
work properly, we need to design an RPC for yearly subscription benefit initialization and another for monthly benefit updates, named initialize_or_reset_yearly_allocation
and allocate_specific_monthly_credit_for_year_plan
respectively.
To synchronize the benefit acquisition logic for monthly and yearly subscriptions, I've also 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 execute the complete SQL statements 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 Yearly 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 looked 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 yearly 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 yearly subscription, then handle separately to minimize 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 the program doesn't need to do the next step
}
// New: Yearly 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 flow on your page. After completion, check the newly added data in Supabase database's orders
, subscriptions
, and usage
tables to confirm they match your design.
Step 4: Update User Benefit Display
This step requires adding yearly subscription acquisition and update logic based on the existing monthly subscription benefit acquisition 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 the default returned data:
const defaultUserBenefits: UserBenefits = {
activePlanId: null,
subscriptionStatus: null,
currentPeriodEnd: null,
nextCreditDate: null,
totalAvailableCredits: 0,
subscriptionCreditsBalance: 0,
oneTimeCreditsBalance: 0,
};
// The `getUserBenefits` method code is too long, this document only shows the approach, please check the source code for complete implementation
export async function getUserBenefits(userId: string): Promise<UserBenefits> {
if (!userId) {
return defaultUserBenefits;
}
// For users to get their own data, subject to RLS policies
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 yearly 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. Even if users don't log in or use membership benefits for several months, they can still track accurate monthly periods after logging in. Example scenario:
- User becomes a yearly member in June, at which point the
usage
table records that the next credit reallocation month is July- But the user doesn't log in during July and doesn't log back in to use the product until August
- This creates 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 according to business requirements. The display method provided here is only 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 also 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 yearly 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 yearly 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 yearly subscriptions with monthly credit allocation.
Conclusion
Nexty.dev's pricing and payment functionality leads other Next.js SaaS templates in completeness, security, and flexibility. However, due to significant differences in subscription benefit designs across different products, we cannot preinstall all personalized requirements into the template.
The Nexty.dev template aims to help template users more easily implement their required payment functionality through visual management (admin backend for creating and editing pricing), complete payment flows, (webhook event processing), controllable customization steps (code blocks marked with [custom]), and documentation explaining implementation approaches.