Menu

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:

  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 times
  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: 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

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 plan

add card

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

add card
add card

In the Benefits JSON field, enter your designed annual pricing information, which will be used for 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 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 simultaneously
  • remaining_months: Records how many months of the annual subscription haven't distributed credits
  • next_credit_date: Records the date for next credit distribution
  • monthly_credit_amount: Records how many credits to distribute monthly, from the credits_per_month we set in the pricing plan
  • last_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:

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 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:

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 default return 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
// `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:

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 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.