Build a Savings Goal App with Scheduled Debits
Build a savings goal app by collecting the user's first deposit via Paystack (which tokenizes their card), saving the authorization_code, and running scheduled debits using Charge Authorization on a cron job. When the goal is reached, pay out via Paystack Transfers. The user never has to re-enter their card.
Goal Setup and Card Tokenization
Tables: savings_goals (user_id, name, target_amount, saved_amount, frequency, next_debit_date, authorization_code, status), savings_transactions (goal_id, type, amount, paystack_ref, status, created_at).
// Step 1: First deposit — tokenizes the card
async function createGoalAndFirstDeposit(userId, userEmail, goalName, targetAmount, firstDepositAmount, frequency) {
var goal = await db.savingsGoals.create({
user_id: userId, name: goalName, target_amount: targetAmount,
saved_amount: 0, frequency, status: 'pending_first_deposit',
});
var reference = 'SAVE-' + goal.id + '-' + Date.now();
var res = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
email: userEmail, amount: firstDepositAmount, currency: 'NGN', reference,
metadata: { goal_id: goal.id, user_id: userId, deposit_type: 'first' },
}),
});
return (await res.json()).data.authorization_url;
}
// Webhook: save authorization_code from first deposit
if (event.event === 'charge.success' && event.data.metadata.deposit_type === 'first') {
var goalId = event.data.metadata.goal_id;
var authCode = event.data.authorization.authorization_code;
var nextDebit = getNextDebitDate(new Date(), goal.frequency); // 'weekly' or 'monthly'
await db.savingsGoals.update(goalId, {
authorization_code: authCode,
saved_amount: event.data.amount,
next_debit_date: nextDebit,
status: 'active',
});
await db.savingsTransactions.create({ goal_id: goalId, type: 'deposit', amount: event.data.amount, paystack_ref: event.data.reference, status: 'success' });
}
Scheduled Debits and Goal Completion
// Cron job — runs daily, processes goals due today
async function processScheduledDebits() {
var today = new Date().toISOString().split('T')[0];
var dueGoals = await db.savingsGoals.findDueToday(today);
for (var goal of dueGoals) {
var user = await db.users.findById(goal.user_id);
var debitAmount = goal.debit_amount; // fixed amount per period
try {
var res = await fetch('https://api.paystack.co/charge/authorization', {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
email: user.email,
amount: debitAmount,
authorization_code: goal.authorization_code,
metadata: { goal_id: goal.id, user_id: goal.user_id, deposit_type: 'scheduled' },
}),
});
var data = await res.json();
if (data.data.status === 'success') {
var newSaved = goal.saved_amount + debitAmount;
await db.savingsGoals.update(goal.id, {
saved_amount: newSaved,
next_debit_date: getNextDebitDate(new Date(), goal.frequency),
});
if (newSaved >= goal.target_amount) {
await db.savingsGoals.update(goal.id, { status: 'goal_reached' });
await notifyGoalReached(goal.user_id, goal);
}
} else {
await db.savingsTransactions.create({ goal_id: goal.id, type: 'deposit', amount: debitAmount, status: 'failed' });
await sendDebitFailedNotification(user, goal);
}
} catch (err) {
console.error('Debit failed for goal', goal.id, err);
}
}
}
// User requests withdrawal after goal is reached
async function withdrawSavings(goalId, userId) {
var goal = await db.savingsGoals.findById(goalId);
if (goal.status !== 'goal_reached') throw new Error('Goal not yet reached');
var user = await db.users.findById(userId);
await fetch('https://api.paystack.co/transfer', {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ source: 'balance', amount: goal.saved_amount, recipient: user.paystack_recipient_code, reason: 'Savings goal: ' + goal.name }),
});
await db.savingsGoals.update(goalId, { status: 'withdrawn' });
}
Learn More
See build a water delivery subscription service for a related recurring charge pattern.
Key Takeaways
- ✓Use card tokenization (authorization_code from the first charge) for all subsequent scheduled debits.
- ✓Run scheduled debits via a cron job using the Paystack Charge Authorization endpoint.
- ✓Handle debit failures gracefully: retry once, then notify the user to update their card.
- ✓Pay out savings via Paystack Transfers when the goal is met — to bank account or M-Pesa.
- ✓A savings product holds user funds — ensure you have appropriate licensing if you hold funds overnight.
Frequently Asked Questions
- Is holding customer savings funds legal without a licence in Nigeria or Kenya?
- Holding customer funds overnight (acting as a store of value) generally requires a financial services licence — a Payment Service Bank (PSB) licence in Nigeria or an e-money licence in Kenya. A simpler approach for early-stage products: use a regulated custodian partner (a licensed bank or fintech) to hold funds while your app handles the UX. Consult a financial regulatory lawyer before launching a savings product.
- What happens if the user's card expires between debits?
- The charge will fail with an expired card error. Send the user a notification to update their payment method. Provide a re-authorization link (new Paystack checkout) where they can pay with a new card — this gives you a new authorization_code to replace the old one. Until they update, pause the scheduled debits.
- Can users withdraw early (before reaching the goal)?
- Yes, but make this a deliberate decision with clear consequences. An early withdrawal could include a penalty (e.g., lose 2% of saved amount) or simply cancel the goal with a full withdrawal. State the early withdrawal policy clearly at goal creation. This policy should balance user flexibility with the savings discipline you want to encourage.
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