Menu

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:

  1. pricing_plans - Pricing plans table

    • benefits_jsonb - Benefits configuration (e.g., {"monthly_credits": 100})
    • recurring_interval - Billing cycle (month/year)
    • payment_type - Payment type (recurring/one_time)
  2. subscriptions - Subscription status table

    • Tracks Stripe subscription status
    • Records billing cycle timing
  3. orders - Order records table

    • Records all payment events
    • Supports multiple order types
  4. usage - Usage management table

    • subscription_credits_balance - Subscription credit balance
    • one_time_credits_balance - One-time credit balance
    • balance_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

add price

Then copy the Price ID

add price

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

add card

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

add card

add card

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

add card

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 simultaneously
  • remaining_months: Records how many months of yearly subscription remain unallocated
  • next_credit_date: Records the next credit allocation date
  • monthly_credit_amount: Records monthly credit allocation amount, from the credits_per_month in the pricing card
  • last_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:

lib/stripe/webhook-handlers.ts
if (planId && userId && subscription) {
  // --- [custom] Upgrade the user's benefits ---
  upgradeSubscriptionCredits(userId, planId, invoiceId, subscription);
  // --- End: [custom] Upgrade the user's benefits ---
}
lib/stripe/webhook-handlers.ts
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:

lib/stripe/actions.ts
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:

lib/stripe/actions.ts
const defaultUserBenefits: UserBenefits = {
  activePlanId: null,
  subscriptionStatus: null,
  currentPeriodEnd: null,
  nextCreditDate: null,
  totalAvailableCredits: 0,
  subscriptionCreditsBalance: 0,
  oneTimeCreditsBalance: 0,
};
lib/stripe/actions.ts
// 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:

components/layout/CurrentUserBenefitsDisplay.tsx
// ...
 
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.