Bonaventure OgetoBy Bonaventure Ogeto|

Build an Invoicing and Payment Reminder Tool

Build an invoicing tool by creating invoice records, generating a Paystack transaction link for each invoice, and marking the invoice as paid when the charge.success webhook fires with the matching reference. A daily cron job checks overdue invoices and sends reminder emails at defined intervals.

Invoice Creation and Payment Link

Tables: invoices (issuer_id, client_name, client_email, items[], subtotal, tax, total, currency, due_date, status, paystack_ref, paid_at), reminder_log (invoice_id, reminder_type, sent_at).

async function createInvoice(issuerId, clientEmail, clientName, items, dueDate) {
  var subtotal = items.reduce(function(sum, item) { return sum + (item.qty * item.unit_price); }, 0);
  var tax = Math.floor(subtotal * 0.075); // 7.5% VAT (Nigeria)
  var total = subtotal + tax;
  var reference = 'INV-' + Date.now();

  var invoice = await db.invoices.create({
    issuer_id: issuerId,
    client_name: clientName,
    client_email: clientEmail,
    items,
    subtotal,
    tax,
    total,
    due_date: dueDate,
    status: 'sent',
    paystack_ref: reference,
  });

  // Generate Paystack payment link
  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: clientEmail,
      amount: total,
      currency: 'NGN',
      reference,
      metadata: { invoice_id: invoice.id, issuer_id: issuerId },
    }),
  });
  var paymentUrl = (await res.json()).data.authorization_url;

  await sendInvoiceEmail(clientEmail, clientName, invoice, paymentUrl);
  return { invoice, paymentUrl };
}

// Webhook: mark invoice paid
if (event.event === 'charge.success') {
  var meta = event.data.metadata;
  if (meta.invoice_id) {
    await db.invoices.update(meta.invoice_id, { status: 'paid', paid_at: new Date() });
    await generateAndSendReceipt(meta.invoice_id, event.data.reference);
  }
}

Overdue Detection and Automated Reminders

// Cron: runs daily to detect overdue invoices and send reminders
async function processOverdueInvoices() {
  var today = new Date();
  var pendingInvoices = await db.invoices.findAll({ status: 'sent' });

  for (var invoice of pendingInvoices) {
    var daysOverdue = Math.floor((today - new Date(invoice.due_date)) / (1000 * 60 * 60 * 24));
    if (daysOverdue <= 0) continue; // not yet overdue

    var remindersAlreadySent = await db.reminderLog.findByInvoice(invoice.id);
    var sentTypes = remindersAlreadySent.map(function(r) { return r.reminder_type; });

    var toSend = null;
    if (daysOverdue >= 3 && !sentTypes.includes('3day'))  toSend = '3day';
    if (daysOverdue >= 7 && !sentTypes.includes('7day'))  toSend = '7day';
    if (daysOverdue >= 14 && !sentTypes.includes('14day')) toSend = '14day';

    if (toSend) {
      // Re-initialize payment link (same reference re-usable until paid)
      var paymentUrl = await getPaymentUrl(invoice.paystack_ref, invoice.client_email, invoice.total);
      await sendReminderEmail(invoice.client_email, invoice.client_name, invoice, paymentUrl, toSend);
      await db.reminderLog.create({ invoice_id: invoice.id, reminder_type: toSend, sent_at: new Date() });

      if (toSend === '14day') {
        await db.invoices.update(invoice.id, { status: 'overdue' });
        await notifyIssuer(invoice.issuer_id, 'Invoice #' + invoice.id + ' is 14 days overdue.');
      }
    }
  }
}

// Mark invoice as void if client and issuer agree it should not be paid
async function voidInvoice(invoiceId) {
  var invoice = await db.invoices.findById(invoiceId);
  if (invoice.status === 'paid') throw new Error('Cannot void a paid invoice');
  await db.invoices.update(invoiceId, { status: 'void' });
}

Learn More

See build a freelance escrow style platform for a related tool that holds payment until work is approved.

Sign up for the McTaba newsletter

Key Takeaways

  • One Paystack payment link per invoice — embed it in the invoice email so clients can pay in one click.
  • Mark invoices as paid via the charge.success webhook, not via manual reconciliation.
  • Automated reminders at 3, 7, and 14 days overdue without manual follow-up.
  • Support partial payments: track amount_paid vs invoice total.
  • Generate a PDF receipt on payment confirmation for the client's records.

Frequently Asked Questions

Can I let clients pay in installments against one invoice?
Yes. Track amount_paid on the invoice. For each partial payment, initialize a new Paystack transaction for the remaining balance and store the reference. Update amount_paid in each charge.success webhook. Mark the invoice as paid when amount_paid equals the total. Show the client their outstanding balance in each reminder.
What currency should I use for invoices sent to international clients?
Paystack supports USD for Nigerian businesses with the right account type. For invoices to international clients, use USD, initialize the Paystack transaction with currency: "USD", and the client pays in USD. Settlement comes to you in NGN at the rate Paystack applies. Check current Paystack multicurrency documentation for your account type.
How do I handle a client who disputes the invoice amount?
Add a "Request adjustment" flow: the client clicks a link in the invoice email, explains the dispute, and you receive a notification. You can void the original invoice and create a revised one. Until the dispute is resolved, pause automated reminders for that invoice to avoid annoying an already-engaged client.

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