Proration and Plan Upgrades with Paystack
To prorate a plan change on Paystack: calculate the unused portion of the old plan, calculate the cost of the new plan for the remaining days, charge the difference via charge_authorization (for upgrades) or store a credit (for downgrades), then cancel the old subscription and create a new one on the new plan. Paystack does not do any of this for you.
Why Proration Matters
A customer on your Basic plan (2,000 NGN/month) wants to upgrade to Pro (5,000 NGN/month) on the 15th of the month, halfway through their billing cycle. What do they pay?
Without proration: You charge the full 5,000 NGN for Pro immediately, and the customer effectively loses the 1,000 NGN of unused Basic time. Or you wait until the next billing cycle, and the customer does not get Pro features for two weeks.
With proration: You calculate the unused Basic portion (about 1,000 NGN for the remaining 15 days), calculate the Pro cost for those 15 days (about 2,500 NGN), and charge the difference (about 1,500 NGN). The customer pays fairly for what they use.
Paystack does not do any of this for you. The Subscriptions API charges the plan amount on the billing date. It has no concept of mid-cycle changes or prorated amounts. You implement proration in your application.
This article is part of the subscriptions and recurring billing guide.
The Proration Math
Proration is about calculating the daily rate for each plan and applying it to the remaining days in the billing period.
function calculateProration(params) {
var oldPlanAmount = params.oldPlanAmount;
var newPlanAmount = params.newPlanAmount;
var periodStart = new Date(params.periodStart);
var periodEnd = new Date(params.periodEnd);
var changeDate = new Date(params.changeDate);
// Total days in the billing period
var totalDays = Math.ceil((periodEnd - periodStart) / (24 * 60 * 60 * 1000));
// Days already used on the old plan
var daysUsed = Math.ceil((changeDate - periodStart) / (24 * 60 * 60 * 1000));
// Days remaining on the new plan
var daysRemaining = totalDays - daysUsed;
// Daily rates
var oldDailyRate = Math.round(oldPlanAmount / totalDays);
var newDailyRate = Math.round(newPlanAmount / totalDays);
// Credit for unused old plan days
var unusedCredit = oldDailyRate * daysRemaining;
// Cost for remaining days on new plan
var newPlanCost = newDailyRate * daysRemaining;
// The amount to charge (positive) or credit (negative)
var proratedAmount = newPlanCost - unusedCredit;
return {
totalDays: totalDays,
daysUsed: daysUsed,
daysRemaining: daysRemaining,
unusedCredit: unusedCredit,
newPlanCost: newPlanCost,
proratedAmount: proratedAmount,
isUpgrade: proratedAmount > 0,
};
}
// Example: upgrade from Basic (200000 kobo) to Pro (500000 kobo)
// halfway through the month
var result = calculateProration({
oldPlanAmount: 200000,
newPlanAmount: 500000,
periodStart: '2026-07-01',
periodEnd: '2026-07-31',
changeDate: '2026-07-15',
});
// result.proratedAmount would be approximately 160000 kobo (1,600 NGN)
Use Math.round on daily rates to avoid fractional kobo. Small rounding differences (1-2 kobo) are acceptable and expected. Always round in the customer's favor if you want to be generous, or consistently round up if you want to be precise.
The Upgrade Flow
When a customer upgrades, you charge the prorated difference immediately and switch them to the new plan.
async function upgradePlan(userId, newPlanCode) {
var user = await getUser(userId);
var currentSub = await getActiveSubscription(userId);
if (!currentSub) {
throw new Error('No active subscription to upgrade');
}
var oldPlan = await getPlan(currentSub.plan_id);
var newPlan = await getPlanByCode(newPlanCode);
if (newPlan.base_amount <= oldPlan.base_amount) {
throw new Error('New plan must be more expensive for an upgrade. Use downgrade flow instead.');
}
// Calculate proration
var proration = calculateProration({
oldPlanAmount: oldPlan.base_amount,
newPlanAmount: newPlan.base_amount,
periodStart: currentSub.current_period_start,
periodEnd: currentSub.current_period_end,
changeDate: new Date(),
});
// Charge the prorated difference
var paymentMethod = await getDefaultPaymentMethod(userId);
var reference = 'UPGRADE_' + userId + '_' + Date.now();
var chargeResult = await chargeAuthorization(
user.email,
proration.proratedAmount,
paymentMethod.authorization_code,
reference,
oldPlan.currency
);
if (chargeResult.status !== 'success') {
throw new Error('Proration charge failed: ' + chargeResult.gateway_response);
}
// Cancel old subscription on Paystack
await disableSubscription(currentSub.paystack_subscription_code, currentSub.paystack_email_token);
// Create new subscription on the upgraded plan
var newSub = await createSubscription(user.email, newPlanCode, paymentMethod.authorization_code);
// Update your database
await db.query(
'UPDATE billing_accounts SET plan_id = $1, updated_at = NOW() WHERE user_id = $2',
[newPlan.id, userId]
);
return {
proratedCharge: proration.proratedAmount,
chargeReference: reference,
newSubscription: newSub.subscription_code,
};
}
If the prorated charge fails (insufficient funds, card declined), do not proceed with the plan switch. The customer stays on their current plan. Notify them that the upgrade could not be completed and suggest they ensure their card has sufficient funds.
The Downgrade Flow
Downgrades are trickier because the customer has overpaid. They are owed a credit for the difference between what they paid for the current period and what the lower plan costs.
async function downgradePlan(userId, newPlanCode) {
var user = await getUser(userId);
var currentSub = await getActiveSubscription(userId);
var oldPlan = await getPlan(currentSub.plan_id);
var newPlan = await getPlanByCode(newPlanCode);
if (newPlan.base_amount >= oldPlan.base_amount) {
throw new Error('New plan must be cheaper for a downgrade. Use upgrade flow instead.');
}
var proration = calculateProration({
oldPlanAmount: oldPlan.base_amount,
newPlanAmount: newPlan.base_amount,
periodStart: currentSub.current_period_start,
periodEnd: currentSub.current_period_end,
changeDate: new Date(),
});
// proratedAmount is negative for downgrades
var creditAmount = Math.abs(proration.proratedAmount);
// Store the credit in the customer's account
await db.query(
'INSERT INTO billing_credits (user_id, amount, currency, reason, created_at, expires_at) VALUES ($1, $2, $3, $4, NOW(), $5)',
[userId, creditAmount, oldPlan.currency, 'Proration credit from downgrade', null]
);
// Two options for when the downgrade takes effect:
// Option A: Immediate. Switch now, credit the difference.
await disableSubscription(currentSub.paystack_subscription_code, currentSub.paystack_email_token);
var paymentMethod = await getDefaultPaymentMethod(userId);
var newSub = await createSubscription(user.email, newPlanCode, paymentMethod.authorization_code);
// Option B: End of period. Keep the higher plan until the period ends, then switch.
// This avoids the credit complexity but delays the downgrade.
await db.query(
'UPDATE billing_accounts SET plan_id = $1, updated_at = NOW() WHERE user_id = $2',
[newPlan.id, userId]
);
return {
creditAmount: creditAmount,
newPlan: newPlan.name,
};
}
Most SaaS products use Option B for downgrades: the customer keeps their higher-tier features until the end of the current billing period, then switches to the lower plan at the next renewal. This avoids issuing credits and simplifies the flow. Choose the option that makes sense for your business.
Applying Credits on the Next Charge
If you chose immediate downgrade with credits, apply the credit to the next billing cycle.
async function calculateChargeWithCredits(userId, planAmount) {
// Get available credits
var credits = await db.query(
'SELECT id, amount FROM billing_credits WHERE user_id = $1 AND used_at IS NULL AND (expires_at IS NULL OR expires_at > NOW()) ORDER BY created_at ASC',
[userId]
);
var totalCredits = 0;
var creditsToApply = [];
for (var i = 0; i < credits.rows.length; i++) {
var credit = credits.rows[i];
var remaining = planAmount - totalCredits;
if (remaining <= 0) break;
var applyAmount = Math.min(credit.amount, remaining);
totalCredits = totalCredits + applyAmount;
creditsToApply.push({ id: credit.id, amount: applyAmount });
}
var chargeAmount = planAmount - totalCredits;
return {
originalAmount: planAmount,
creditsApplied: totalCredits,
chargeAmount: Math.max(0, chargeAmount),
creditRecords: creditsToApply,
};
}
// After a successful charge, mark credits as used
async function markCreditsUsed(creditRecords) {
for (var i = 0; i < creditRecords.length; i++) {
await db.query(
'UPDATE billing_credits SET used_at = NOW() WHERE id = $1',
[creditRecords[i].id]
);
}
}
If the credit covers the full charge amount, skip the Paystack charge but still create an invoice for record-keeping. Mark the invoice as paid with a note that it was covered by credits.
The Cancel-and-Resubscribe Pattern
Because Paystack plans are immutable (you cannot change the amount on an existing plan), a plan change always requires:
- Cancel (disable) the old subscription
- Create a new subscription on the new plan
async function switchSubscription(userId, newPlanCode) {
var currentSub = await getActiveSubscription(userId);
var user = await getUser(userId);
var paymentMethod = await getDefaultPaymentMethod(userId);
// Cancel old subscription
await axios.post(
'https://api.paystack.co/subscription/disable',
{
code: currentSub.paystack_subscription_code,
token: currentSub.paystack_email_token,
},
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
},
}
);
// Create new subscription
var response = await axios.post(
'https://api.paystack.co/subscription',
{
customer: user.email,
plan: newPlanCode,
authorization: paymentMethod.authorization_code,
},
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
},
}
);
var newSub = response.data.data;
// Update your database
await db.query(
'UPDATE billing_accounts SET paystack_subscription_code = $1, paystack_email_token = $2, updated_at = NOW() WHERE user_id = $3',
[newSub.subscription_code, newSub.email_token, userId]
);
return newSub;
}
Be aware that when you create a new subscription, the billing date resets. If the customer was billed on the 1st of the month and you switch them mid-month, the new subscription's billing date is the day you created it, not the 1st. Plan your proration calculations accordingly.
Edge Cases in Proration
These edge cases trip up billing systems regularly:
Multiple plan changes in one billing period. A customer upgrades from Basic to Pro, then upgrades again from Pro to Enterprise. Calculate each proration independently. The second proration should be based on what they are currently paying (Pro), not the original plan (Basic).
Plan change on the first or last day. If the customer changes on day 1 of the billing period, the proration should be approximately the full difference. If they change on the last day, the proration should be nearly zero. Make sure your math handles both extremes correctly.
Short months. February has 28-29 days, other months have 30-31. Use actual calendar days, not a fixed 30-day assumption. The daily rate for a 5,000 NGN plan in February (28 days) is different from July (31 days).
Annual to monthly switch. If a customer on an annual plan wants to switch to monthly billing, the proration math involves different time scales. Calculate the daily rate of the annual plan, determine the unused days, and credit that amount. Then start monthly billing. See annual vs monthly billing logic.
Failed proration charge. If the prorated upgrade charge fails, do not switch the plan. The customer stays on the old plan. Show them a clear error message and suggest ensuring their card has sufficient funds. Do not leave the customer in a half-switched state.
Key Takeaways
- ✓Paystack does not handle proration. You calculate prorated amounts and manage the plan switch yourself.
- ✓For upgrades, charge the prorated difference immediately via charge_authorization. The customer pays only for the remaining days at the higher rate.
- ✓For downgrades, store a credit for the unused portion of the higher plan. Apply it to the next billing cycle on the lower plan.
- ✓The plan switch requires cancelling the old subscription and creating a new one. Paystack plans are immutable, so you cannot change a plan's price.
- ✓Always calculate proration based on calendar days remaining in the billing period, not based on percentages of the billing interval.
- ✓Document your proration policy clearly for customers. Mid-cycle changes are a common source of billing disputes.
Frequently Asked Questions
- Does Paystack handle proration automatically?
- No. Paystack has no proration feature. You calculate prorated amounts in your application, charge or credit the difference, and manage the subscription switch yourself by cancelling the old subscription and creating a new one.
- Should I prorate downgrades or wait until the end of the billing period?
- Most SaaS products wait until the end of the billing period for downgrades. The customer keeps their higher-tier features for the time they already paid for, and the lower plan starts at the next billing cycle. This avoids the complexity of calculating and managing credits.
- What happens to the billing date when I cancel and recreate a subscription?
- The billing date resets to the creation date of the new subscription. If you want the billing date to stay the same, you need to manage that in your application logic, possibly by using manual charge_authorization instead of the Subscriptions API.
- How do I handle proration for annual plans?
- Calculate the daily rate of the annual plan (annual amount divided by 365), multiply by remaining days, and credit that amount. Annual proration involves larger credits and charges, so make the amounts clear to the customer before they confirm the change.
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