Bonaventure OgetoBy Bonaventure Ogeto|

Annual vs Monthly Billing Logic on Paystack

Each billing interval needs its own Paystack plan. A product with two tiers and two intervals needs four plans. Annual plans are typically priced at 80-85% of the monthly equivalent to incentivize commitment. Switching between intervals requires cancelling the old subscription and creating a new one, with proration for any mid-cycle changes.

Setting Up Monthly and Annual Plans

var plans = [
  { name: 'Basic Monthly', amount: 200000, interval: 'monthly' },
  { name: 'Basic Annual', amount: 2000000, interval: 'annually' },
  { name: 'Pro Monthly', amount: 500000, interval: 'monthly' },
  { name: 'Pro Annual', amount: 5000000, interval: 'annually' },
];

// Basic Monthly: 2,000 NGN/month
// Basic Annual: 20,000 NGN/year (saves 4,000 NGN = 17% discount)
// Pro Monthly: 5,000 NGN/month
// Pro Annual: 50,000 NGN/year (saves 10,000 NGN = 17% discount)

Common discount levels for annual billing: 15-20% off the monthly equivalent. A 17% discount (10 months for the price of 12) is popular and easy to communicate: "Get 2 months free with annual billing."

Store the plan mappings in your config so your application knows which monthly plan corresponds to which annual plan:

var PLAN_MAP = {
  basic: {
    monthly: { code: 'PLN_basic_monthly_xxx', amount: 200000, interval: 'monthly' },
    annual: { code: 'PLN_basic_annual_xxx', amount: 2000000, interval: 'annually' },
  },
  pro: {
    monthly: { code: 'PLN_pro_monthly_xxx', amount: 500000, interval: 'monthly' },
    annual: { code: 'PLN_pro_annual_xxx', amount: 5000000, interval: 'annually' },
  },
};

This article is part of the subscriptions and recurring billing guide.

The Pricing Page Toggle

Most SaaS pricing pages have a "Monthly / Annual" toggle. When toggled, prices update to show the annual rate and the savings.

// API endpoint returning plan data for the pricing page
router.get('/api/pricing', function(req, res) {
  var tiers = [
    {
      name: 'Basic',
      features: ['5 projects', '10 GB storage'],
      monthly: {
        amount: 2000,
        display: '2,000',
        planCode: PLAN_MAP.basic.monthly.code,
      },
      annual: {
        amount: 20000,
        display: '20,000',
        monthlyEquivalent: Math.round(20000 / 12),
        monthlyEquivalentDisplay: '1,667',
        savings: '17%',
        savingsLabel: 'Save 4,000/year',
        planCode: PLAN_MAP.basic.annual.code,
      },
    },
    {
      name: 'Pro',
      features: ['Unlimited projects', '100 GB storage', 'API access'],
      monthly: {
        amount: 5000,
        display: '5,000',
        planCode: PLAN_MAP.pro.monthly.code,
      },
      annual: {
        amount: 50000,
        display: '50,000',
        monthlyEquivalent: Math.round(50000 / 12),
        monthlyEquivalentDisplay: '4,167',
        savings: '17%',
        savingsLabel: 'Save 10,000/year',
        planCode: PLAN_MAP.pro.annual.code,
      },
    },
  ];

  res.json({ tiers: tiers, currency: 'NGN' });
});

On the annual pricing card, show the monthly equivalent prominently: "4,167 NGN/month billed annually" alongside "50,000 NGN/year." This helps customers compare the two options intuitively.

Switching from Monthly to Annual

When a monthly customer wants to switch to annual, you charge a prorated annual amount for the remaining time until the next annual renewal, minus the unused portion of their current monthly payment.

async function switchToAnnual(userId) {
  var billing = await getCurrentBilling(userId);
  var currentPlan = await getPlan(billing.plan_id);
  var annualPlan = getAnnualCounterpart(currentPlan);

  // Calculate what they owe
  var daysLeftInMonth = Math.ceil(
    (new Date(billing.current_period_end) - new Date()) / (24 * 60 * 60 * 1000)
  );
  var dailyRateMonthly = Math.round(currentPlan.base_amount / 30);
  var monthlyCredit = dailyRateMonthly * daysLeftInMonth;

  // Annual charge minus the monthly credit
  var chargeAmount = annualPlan.base_amount - monthlyCredit;

  var user = await getUser(userId);
  var paymentMethod = await getDefaultPaymentMethod(userId);
  var authCode = await getAuthorizationCode(paymentMethod.id);

  // Charge the prorated annual amount
  var reference = 'ANNUAL_SWITCH_' + userId + '_' + Date.now();
  var result = await chargeAuthorization(user.email, chargeAmount, authCode, reference);

  if (result.status !== 'success') {
    return { success: false, reason: result.gateway_response };
  }

  // Cancel old monthly subscription
  await disableSubscription(billing.paystack_subscription_code, billing.paystack_email_token);

  // Create new annual subscription
  var newSub = await createPaystackSubscription(user.email, annualPlan.paystack_plan_code, authCode);

  await updateBillingAccount(userId, {
    planId: annualPlan.id,
    subscriptionCode: newSub.subscription_code,
    emailToken: newSub.email_token,
  });

  return {
    success: true,
    charged: chargeAmount,
    newPlan: annualPlan.name,
    nextRenewal: newSub.next_payment_date,
  };
}

Switching from Annual to Monthly

Annual-to-monthly switches are simpler but the business decision is important. The customer paid for a year. Switching to monthly mid-year raises the question: do you refund the unused annual portion?

Most SaaS products use this approach: the customer keeps their annual plan for the remainder of the year. The monthly plan starts when the annual period ends.

async function switchToMonthlyAtRenewal(userId) {
  var billing = await getCurrentBilling(userId);

  // Schedule the switch for end of annual period
  await db.query(
    'UPDATE billing_accounts SET pending_plan_change = $1, pending_change_date = $2, updated_at = NOW() WHERE user_id = $3',
    ['monthly', billing.current_period_end, userId]
  );

  // Cancel auto-renewal of the annual plan
  await disableSubscription(billing.paystack_subscription_code, billing.paystack_email_token);

  return {
    status: 'scheduled',
    currentPlanActiveUntil: billing.current_period_end,
    message: 'Your annual plan will continue until ' + formatDate(billing.current_period_end) + '. Monthly billing will start after that.',
  };
}

// Run daily: check for pending plan changes
async function processPendingIntervalChanges() {
  var pending = await db.query(
    'SELECT user_id, pending_plan_change FROM billing_accounts WHERE pending_plan_change IS NOT NULL AND pending_change_date <= NOW()',
    []
  );

  for (var i = 0; i < pending.rows.length; i++) {
    var row = pending.rows[i];
    var user = await getUser(row.user_id);
    var paymentMethod = await getDefaultPaymentMethod(row.user_id);
    var monthlyPlan = getMonthlyPlanForTier(row.pending_plan_change);

    var authCode = await getAuthorizationCode(paymentMethod.id);
    var newSub = await createPaystackSubscription(user.email, monthlyPlan.paystack_plan_code, authCode);

    await db.query(
      'UPDATE billing_accounts SET plan_id = $1, paystack_subscription_code = $2, paystack_email_token = $3, pending_plan_change = NULL, pending_change_date = NULL, updated_at = NOW() WHERE user_id = $4',
      [monthlyPlan.id, newSub.subscription_code, newSub.email_token, row.user_id]
    );
  }
}

Offering immediate annual-to-monthly switches with a prorated refund is possible but adds complexity. Most products avoid it by letting the annual term complete first.

Annual Billing Considerations for African Markets

Annual billing in African markets has specific dynamics:

Higher failure risk. Annual charges are large. A 50,000 NGN charge is more likely to fail due to insufficient funds than a 5,000 NGN monthly charge. Consider offering the option to split the annual payment into quarterly payments via manual charges.

Currency fluctuations. For products priced in USD but charged in local currency, annual billing locks in the exchange rate for a year. This can be good or bad depending on currency movement. Consider whether to price in local currency to avoid this issue.

Cash flow patterns. In many African markets, customers may prefer to pay annually if they receive annual bonuses or if their business has seasonal cash flow peaks. Offer annual billing prominently during these periods.

The "2 months free" framing. "Save 17%" sounds abstract. "Get 2 months free" is concrete and compelling. Frame the annual discount in terms the customer can visualize.

For price change management across intervals, see grandfathering old prices on plans.

Key Takeaways

  • Create separate Paystack plans for each billing interval. Monthly and annual plans for the same tier are two distinct plans with different amounts and intervals.
  • Price annual plans at 80-85% of the monthly equivalent to incentivize longer commitments and reduce churn.
  • Switching intervals requires cancelling the old subscription and creating a new one. Handle proration for the transition.
  • Annual-to-monthly switches should take effect at the end of the annual period. Do not refund the remaining annual term.
  • Monthly-to-annual switches can happen immediately with a prorated annual charge for the remaining period.
  • Display the monthly equivalent on the annual pricing card so customers can see the savings.

Frequently Asked Questions

How many Paystack plans do I need for monthly and annual billing?
Two plans per tier. If you have Basic, Pro, and Enterprise tiers, each with monthly and annual options, you need six plans. Each unique price-interval combination requires its own plan.
Can I change a plan from monthly to annual?
No. Paystack plan intervals are immutable. You cannot change a plan's interval after creation. Create separate plans for each interval and switch customers between them by cancelling one subscription and creating another.
How much of a discount should I offer for annual billing?
The industry standard is 15-20% off the monthly price. A 17% discount is popular because it works out to "2 months free" for a 12-month commitment. Test different discount levels and measure which drives more annual signups without sacrificing too much revenue.
What if the annual charge fails due to insufficient funds?
Large charges fail more often. Options: retry according to your dunning schedule, offer to split the annual payment into two or four instalments via charge_authorization, or suggest the customer switch to monthly billing instead.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon