Bonaventure OgetoBy Bonaventure Ogeto|

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

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