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.
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