Building a Freelancer Invoicing Tool for Kenyan Developers
Build a freelancer invoicing tool by creating invoices with line items and tax calculations, generating Paystack payment links attached to each invoice, accepting both KES (for local clients via M-Pesa) and USD (for international clients via card), tracking payment status through webhooks, sending automated overdue reminders via SMS and email, generating PDF receipts after payment, and creating recurring invoices for retainer clients using Paystack Subscriptions or scheduled Payment Requests.
Why Kenyan Freelance Developers Need Proper Invoicing
Most Kenyan freelance developers start the same way: finish a project, send the client an M-Pesa number on WhatsApp, and hope the payment comes through. This works when you have two or three clients. It falls apart when you are juggling eight projects, three currencies, and a tax filing deadline.
Proper invoicing solves five problems at once. First, it makes you look professional. A client who receives a branded PDF invoice with itemized line items and a "Pay Now" button takes you more seriously than one who gets "send to 0712345678 thanks." Second, it tracks what you are owed. No more scrolling through WhatsApp to figure out if Client X paid for the March sprint. Third, it simplifies tax filing. KRA expects income records, and your invoicing tool generates them automatically. Fourth, it handles multi-currency billing. Local clients pay in KES, international clients pay in USD, and everything is tracked in one place. Fifth, it gets you paid faster. A payment link in the invoice body reduces friction from "I will send tomorrow" to a 30-second M-Pesa transaction.
Paystack provides the payment infrastructure. You build the invoice creation, client management, and tax tracking around it.
Data Model: Clients, Invoices, and Line Items
Your database needs four tables to start.
const schema = `
CREATE TABLE clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
freelancer_id UUID REFERENCES users(id),
name TEXT NOT NULL,
email TEXT,
phone TEXT,
company TEXT,
currency TEXT DEFAULT 'KES', -- preferred billing currency
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_number TEXT UNIQUE NOT NULL, -- e.g., INV-2026-0042
freelancer_id UUID REFERENCES users(id),
client_id UUID REFERENCES clients(id),
currency TEXT NOT NULL,
subtotal INT NOT NULL, -- before tax, in smallest unit
tax_amount INT DEFAULT 0,
total_amount INT NOT NULL, -- subtotal + tax
status TEXT DEFAULT 'draft', -- draft, sent, paid, overdue, cancelled
due_date DATE NOT NULL,
paystack_reference TEXT,
payment_link TEXT, -- Paystack checkout URL
paid_at TIMESTAMPTZ,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE line_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID REFERENCES invoices(id) ON DELETE CASCADE,
description TEXT NOT NULL,
quantity DECIMAL(10,2) DEFAULT 1,
unit_price INT NOT NULL, -- in smallest unit
total INT NOT NULL,
sort_order INT DEFAULT 0
);
CREATE TABLE invoice_reminders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID REFERENCES invoices(id),
reminder_type TEXT NOT NULL, -- 'gentle', 'firm', 'final'
sent_at TIMESTAMPTZ DEFAULT NOW()
);
`;
Invoice numbering. Use a sequential, human-readable format: INV-2026-0001, INV-2026-0002, etc. The year prefix resets the counter each year and makes it easy to reference invoices in conversations with clients. Store the counter per freelancer so each user has their own sequence.
Line items. Each invoice has one or more line items. A typical developer invoice might have: "Frontend development, 40 hours at KES 2,500/hour," "API integration, 15 hours at KES 3,000/hour," and "Deployment and DevOps setup, flat fee KES 15,000." Line items give the client transparency and reduce disputes about what they are paying for.
The client table. Track each client's preferred currency, contact details, and billing history. When creating a new invoice for a returning client, auto-fill their details. Over time, this table becomes a valuable business asset: you can see which clients generate the most revenue, which ones pay on time, and which ones are chronically late.
Creating Invoices with Paystack Payment Links
When a freelancer creates an invoice, your app generates a Paystack transaction and attaches the payment link to the invoice. The client receives the invoice (via email or WhatsApp) and clicks the link to pay.
async function createAndSendInvoice(invoiceData) {
// Calculate totals
let subtotal = 0;
for (const item of invoiceData.lineItems) {
const itemTotal = Math.round(item.quantity * item.unitPrice);
subtotal += itemTotal;
}
const taxRate = invoiceData.includeTax ? 0.16 : 0; // 16% VAT if registered
const taxAmount = Math.floor(subtotal * taxRate);
const totalAmount = subtotal + taxAmount;
const invoiceNumber = await generateInvoiceNumber(invoiceData.freelancerId);
const reference = `inv_${invoiceNumber}_${Date.now()}`;
// Create invoice record
const invoice = await db.query(
`INSERT INTO invoices
(invoice_number, freelancer_id, client_id, currency, subtotal,
tax_amount, total_amount, due_date, paystack_reference, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'draft')
RETURNING *`,
[
invoiceNumber, invoiceData.freelancerId, invoiceData.clientId,
invoiceData.currency, subtotal, taxAmount, totalAmount,
invoiceData.dueDate, reference,
]
);
// Insert line items
for (const item of invoiceData.lineItems) {
await db.query(
`INSERT INTO line_items (invoice_id, description, quantity, unit_price, total, sort_order)
VALUES ($1, $2, $3, $4, $5, $6)`,
[invoice.rows[0].id, item.description, item.quantity, item.unitPrice,
Math.round(item.quantity * item.unitPrice), item.sortOrder]
);
}
// Initialize Paystack transaction
const client = await getClient(invoiceData.clientId);
const channels = invoiceData.currency === 'KES'
? ['mobile_money', 'card']
: ['card'];
const paystackResponse = await axios.post(
'https://api.paystack.co/transaction/initialize',
{
email: client.email || `${client.phone}@invoice.yourapp.com`,
amount: totalAmount,
reference: reference,
currency: invoiceData.currency,
channels: channels,
callback_url: `https://yourapp.com/invoices/${invoice.rows[0].id}/paid`,
metadata: {
invoice_id: invoice.rows[0].id,
invoice_number: invoiceNumber,
client_name: client.name,
custom_fields: [
{ display_name: 'Invoice', variable_name: 'invoice_number', value: invoiceNumber },
{ display_name: 'Client', variable_name: 'client', value: client.name },
],
},
},
{ headers: { Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}` } }
);
// Update invoice with payment link
const paymentLink = paystackResponse.data.data.authorization_url;
await db.query(
'UPDATE invoices SET payment_link = $1, status = $2 WHERE id = $3',
[paymentLink, 'sent', invoice.rows[0].id]
);
return { invoice: invoice.rows[0], paymentLink };
}
Sending the invoice. Send the invoice to the client via email (with a PDF attachment and the payment link in the body) and optionally via SMS or WhatsApp (with a shorter message and the payment link). The SMS version might say: "Invoice INV-2026-0042 from [Your Name]: KES 125,000. Pay here: [link]. Due: July 30."
Multi-Currency: KES for Local, USD for International
Kenyan freelance developers often bill local clients in KES and international clients in USD. Paystack supports multiple currencies on a single account, so you do not need separate integrations.
How it works. When initializing a transaction, set the currency parameter to 'KES' or 'USD'. For KES transactions, Paystack offers M-Pesa and card channels. For USD transactions, only card is available (M-Pesa is KES-only). Paystack settles the payment to your balance in the original currency.
// KES invoice for a Nairobi client
const kesInvoice = {
amount: 12500000, // KES 125,000
currency: 'KES',
channels: ['mobile_money', 'card'],
};
// USD invoice for a remote client
const usdInvoice = {
amount: 150000, // USD 1,500 (in cents)
currency: 'USD',
channels: ['card'],
};
Exchange rate tracking. If you bill in USD but track income in KES for tax purposes, record the exchange rate at the time of payment. You can get the rate from Paystack's transaction data or from the Central Bank of Kenya daily rate. Store both the original currency amount and the KES equivalent in your records.
Client currency preference. Store each client's preferred currency in the clients table. When creating a new invoice for that client, default to their currency. A local startup pays in KES. A US-based company pays in USD. Do not make the freelancer remember each time.
Displaying amounts. Format currency correctly: KES 125,000 (not 125000 or $125,000) and USD 1,500.00 (with decimals for cents). Small detail, but it signals professionalism on the invoice.
Payment Tracking and Overdue Reminders
Once an invoice is sent, track its status through the lifecycle: sent, viewed (if you track email opens), paid, or overdue.
async function handleInvoicePaymentSuccess(reference) {
const verification = await verifyTransaction(reference);
if (verification.status !== 'success') return;
const invoice = await db.query(
`UPDATE invoices SET status = 'paid', paid_at = NOW()
WHERE paystack_reference = $1 AND status IN ('sent', 'overdue')
RETURNING *`,
[reference]
);
if (invoice.rowCount === 0) return;
const inv = invoice.rows[0];
// Generate receipt
const receiptUrl = await generatePDFReceipt(inv);
// Notify the freelancer
await sendSMS(inv.freelancer_phone, `Invoice ${inv.invoice_number} paid! ${formatCurrency(inv.total_amount, inv.currency)} received.`);
// Send receipt to client
await sendEmail(inv.client_email, {
subject: `Receipt for ${inv.invoice_number}`,
body: `Thank you for your payment. Receipt attached.`,
attachment: receiptUrl,
});
}
Overdue detection. Run a daily job that marks unpaid invoices past their due date as "overdue."
async function checkOverdueInvoices() {
const overdue = await db.query(
`UPDATE invoices SET status = 'overdue'
WHERE status = 'sent' AND due_date < CURRENT_DATE
RETURNING *`
);
for (const invoice of overdue.rows) {
// Send first overdue notification to freelancer
await sendSMS(
invoice.freelancer_phone,
`Invoice ${invoice.invoice_number} is overdue. ${formatCurrency(invoice.total_amount, invoice.currency)} from ${invoice.client_name}.`
);
}
}
Automated reminders. Send reminders on a schedule after the due date. Do not spam. Three reminders is usually enough.
async function sendOverdueReminders() {
const overdueInvoices = await db.query(
"SELECT * FROM invoices WHERE status = 'overdue'"
);
for (const invoice of overdueInvoices.rows) {
const daysPastDue = Math.floor(
(Date.now() - new Date(invoice.due_date).getTime()) / (1000 * 60 * 60 * 24)
);
// Check which reminders have already been sent
const sentReminders = await db.query(
'SELECT reminder_type FROM invoice_reminders WHERE invoice_id = $1',
[invoice.id]
);
const sentTypes = sentReminders.rows.map(r => r.reminder_type);
let reminderType = null;
let message = '';
if (daysPastDue >= 3 && !sentTypes.includes('gentle')) {
reminderType = 'gentle';
message = `Hi, just a reminder that invoice ${invoice.invoice_number} for ${formatCurrency(invoice.total_amount, invoice.currency)} was due on ${formatDate(invoice.due_date)}. Pay here: ${invoice.payment_link}`;
} else if (daysPastDue >= 7 && !sentTypes.includes('firm')) {
reminderType = 'firm';
message = `Invoice ${invoice.invoice_number} is now 7 days overdue. Amount: ${formatCurrency(invoice.total_amount, invoice.currency)}. Please settle at your earliest convenience: ${invoice.payment_link}`;
} else if (daysPastDue >= 14 && !sentTypes.includes('final')) {
reminderType = 'final';
message = `Final notice: Invoice ${invoice.invoice_number} for ${formatCurrency(invoice.total_amount, invoice.currency)} is 14 days overdue. Please make payment today: ${invoice.payment_link}`;
}
if (reminderType) {
await sendEmailToClient(invoice.client_email, message, invoice.payment_link);
await db.query(
'INSERT INTO invoice_reminders (invoice_id, reminder_type) VALUES ($1, $2)',
[invoice.id, reminderType]
);
}
}
}
Tone matters. The 3-day reminder is a polite nudge. The 7-day reminder is professional but firm. The 14-day reminder is direct. After that, the freelancer handles it personally. Automated reminders handle the awkward "Hey, did you forget to pay me?" conversation so the developer does not have to.
Recurring Invoices for Retainer Clients
Retainer clients pay a fixed amount every month for ongoing work. Instead of creating a new invoice manually each month, automate it.
Option A: Paystack Subscriptions. Create a Paystack Plan for the retainer amount and subscribe the client. Paystack handles automatic charging on the billing cycle. You receive a charge.success webhook for each successful renewal and generate the invoice and receipt automatically.
Option B: Scheduled invoice generation. If you prefer more control (or if the retainer amount varies slightly each month), use a cron job to create and send invoices on a schedule. Each invoice gets its own Paystack payment link, and the client pays manually. This is less automated but gives flexibility for adjustments.
// Recurring invoice template
const recurringInvoices = `
CREATE TABLE recurring_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
freelancer_id UUID REFERENCES users(id),
client_id UUID REFERENCES clients(id),
description TEXT NOT NULL,
amount INT NOT NULL,
currency TEXT NOT NULL,
frequency TEXT NOT NULL, -- 'monthly', 'bi-weekly'
day_of_month INT DEFAULT 1,
next_invoice_date DATE NOT NULL,
active BOOLEAN DEFAULT true
);
`;
// Monthly job to generate recurring invoices
async function generateRecurringInvoices() {
const today = new Date().toISOString().split('T')[0];
const templates = await db.query(
`SELECT * FROM recurring_templates
WHERE active = true AND next_invoice_date <= $1`,
[today]
);
for (const template of templates.rows) {
// Create invoice from template
await createAndSendInvoice({
freelancerId: template.freelancer_id,
clientId: template.client_id,
currency: template.currency,
dueDate: addDays(today, 14), // 14-day payment terms
lineItems: [
{
description: template.description,
quantity: 1,
unitPrice: template.amount,
sortOrder: 0,
},
],
});
// Advance next invoice date
const nextDate = template.frequency === 'monthly'
? addMonths(template.next_invoice_date, 1)
: addWeeks(template.next_invoice_date, 2);
await db.query(
'UPDATE recurring_templates SET next_invoice_date = $1 WHERE id = $2',
[nextDate, template.id]
);
}
}
Option B is better for most Kenyan freelancers because retainer scopes change. One month you bill for 40 hours, the next for 35. A template with manual review before sending is more practical than fully automated billing for variable amounts.
KRA Tax Considerations for Freelancer Invoicing
Freelance income in Kenya is taxable. Your invoicing tool should help developers stay compliant without turning them into accountants.
Income tax. Freelancers earning above the KRA threshold must file annual returns and pay income tax. Your tool tracks total income by month and year, making it straightforward to report earnings during filing season. Generate a yearly income summary that shows: total invoiced, total received, total outstanding, and a currency breakdown (KES and USD).
VAT. Freelancers registered for VAT (required above a certain annual turnover) must charge 16% VAT on their services. Your invoice should show the subtotal, VAT amount, and total separately. Store the freelancer's VAT registration status and KRA PIN so these details appear on invoices automatically.
function calculateInvoiceTotals(lineItems, isVATRegistered) {
const subtotal = lineItems.reduce(
(sum, item) => sum + Math.round(item.quantity * item.unitPrice), 0
);
const vatRate = isVATRegistered ? 0.16 : 0;
const vatAmount = Math.floor(subtotal * vatRate);
const total = subtotal + vatAmount;
return { subtotal, vatAmount, total };
}
eTIMS compliance. KRA's electronic Tax Invoice Management System (eTIMS) requires businesses to generate tax-compliant invoices through approved systems. If your users are VAT-registered, consider integrating with the eTIMS API to generate compliant invoice numbers. This is an advanced feature but increasingly important as KRA tightens enforcement.
Receipt generation. After payment, generate a PDF receipt that includes: the freelancer's name and KRA PIN, the client's name and KRA PIN (if known), invoice number, payment date, amounts, and VAT breakdown. Both the freelancer and client need this document for their own tax records.
Withholding tax. Some corporate clients in Kenya withhold tax on payments to freelancers and remit it directly to KRA. If a client pays KES 100,000 but withholds 5% (KES 5,000), record the payment as KES 95,000 received plus KES 5,000 withheld. The freelancer can claim the withheld amount as a credit during tax filing. Track withholding tax separately in your invoice records.
For the full picture on integrating Paystack in the Kenyan market, see our Paystack in Kenya hub guide.
Receipt Generation and Financial Reports
Receipts close the loop. After a client pays, generate a PDF receipt automatically and send it to both the client and the freelancer.
Receipt content. A proper receipt includes: the freelancer's business name and KRA PIN, the client's name and company, the invoice number and payment date, a line-item breakdown matching the original invoice, the payment method (M-Pesa or card), the Paystack transaction reference, and the total amount paid including any tax.
Financial dashboard. Give freelancers a dashboard showing: total revenue this month, outstanding invoices, overdue invoices (highlighted), top clients by revenue, and monthly income trend over the past 12 months. This dashboard is not just a convenience. It is the view that helps a freelancer decide whether to raise their rates, take on more clients, or take a month off.
async function getFreelancerDashboard(freelancerId) {
const currentMonth = new Date().toISOString().slice(0, 7); // YYYY-MM
const [monthlyRevenue, outstanding, overdue, topClients, yearlyTrend] = await Promise.all([
db.query(
`SELECT COALESCE(SUM(total_amount), 0) as total
FROM invoices
WHERE freelancer_id = $1 AND status = 'paid'
AND TO_CHAR(paid_at, 'YYYY-MM') = $2`,
[freelancerId, currentMonth]
),
db.query(
`SELECT COUNT(*) as count, COALESCE(SUM(total_amount), 0) as total
FROM invoices
WHERE freelancer_id = $1 AND status = 'sent'`,
[freelancerId]
),
db.query(
`SELECT COUNT(*) as count, COALESCE(SUM(total_amount), 0) as total
FROM invoices
WHERE freelancer_id = $1 AND status = 'overdue'`,
[freelancerId]
),
db.query(
`SELECT c.name, SUM(i.total_amount) as total_revenue, COUNT(*) as invoice_count
FROM invoices i JOIN clients c ON i.client_id = c.id
WHERE i.freelancer_id = $1 AND i.status = 'paid'
GROUP BY c.id, c.name ORDER BY total_revenue DESC LIMIT 5`,
[freelancerId]
),
db.query(
`SELECT TO_CHAR(paid_at, 'YYYY-MM') as month, SUM(total_amount) as revenue
FROM invoices
WHERE freelancer_id = $1 AND status = 'paid'
AND paid_at >= NOW() - INTERVAL '12 months'
GROUP BY month ORDER BY month`,
[freelancerId]
),
]);
return {
monthlyRevenue: monthlyRevenue.rows[0].total,
outstanding: outstanding.rows[0],
overdue: overdue.rows[0],
topClients: topClients.rows,
yearlyTrend: yearlyTrend.rows,
};
}
Export. Let freelancers export their invoice data to CSV for accountants or for importing into accounting software like QuickBooks or Wave. The export should include: invoice number, client, date, due date, amount, currency, status, and payment date. This CSV becomes the primary input for tax filing.
Key Takeaways
- ✓Generate a unique Paystack payment link for each invoice. The client clicks the link, pays via M-Pesa or card, and the invoice is automatically marked as paid through the charge.success webhook.
- ✓Support both KES and USD on a single Paystack account. Local Kenyan clients pay in KES via M-Pesa. International clients pay in USD via card. Paystack handles the currency routing.
- ✓Automate overdue reminders. Send a polite nudge 3 days after the due date, a firmer reminder at 7 days, and a final notice at 14 days. Most late-paying clients are simply busy, not malicious.
- ✓Generate PDF receipts after payment. Clients need them for their own accounting, and you need them for your KRA records.
- ✓Track income by client, month, and currency for tax filing. The Kenya Revenue Authority expects freelancers earning above the threshold to file and pay income tax. Your invoicing tool doubles as your bookkeeping system.
- ✓Use Paystack Payment Requests for one-off invoices and Subscriptions for recurring retainer billing. Both are built into the API.
- ✓Include KRA PIN and tax details on invoices. Professional invoices with proper tax information build client confidence and keep you compliant.
Frequently Asked Questions
- Can Paystack handle both KES and USD invoices on the same account?
- Yes. Paystack supports multiple currencies. Set the currency parameter when initializing the transaction. KES transactions offer M-Pesa and card channels. USD transactions are card-only. Settlements happen per currency. Check your Paystack dashboard to ensure multi-currency is enabled for your account, as it may require activation.
- What if a client pays the invoice partially?
- Paystack transactions are for fixed amounts, so a client paying less than the invoice total would need a separate transaction for the remaining balance. Handle this by tracking partial payments in your database: when a payment comes in for less than the invoice total, update the balance due and generate a new payment link for the remainder. Mark the invoice as "partially paid" instead of "paid."
- How do I handle late payment fees?
- Add a late fee policy to your invoice terms (e.g., "2% per month on overdue balances"). When an invoice becomes overdue, calculate the late fee and add it as a separate line item on a new invoice or include it in the overdue reminder. Whether to enforce late fees is a business decision. Many Kenyan freelancers skip late fees to maintain client relationships but use the policy as leverage in conversations about timely payment.
- Should freelancers register for VAT?
- VAT registration is required when your annual taxable turnover exceeds the KRA threshold. Below that threshold, registration is optional. If you register, you must charge 16% VAT on all invoices and file monthly VAT returns. Consult a tax professional for current thresholds and advice specific to your situation. Your invoicing tool should support both VAT-inclusive and VAT-exclusive invoicing.
- Can I use Paystack Payment Requests instead of Initialize Transaction for invoicing?
- Yes. Paystack Payment Requests are designed exactly for invoicing. You create a Payment Request with the client email, amount, and due date. Paystack sends the payment request email to the client with a payment link. This is simpler than Initialize Transaction for basic invoicing. However, Initialize Transaction gives you more control over channels, metadata, and callback URLs. Use Payment Requests for simplicity or Initialize Transaction for customization.
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