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.
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.
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.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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,
};
}
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 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.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course