Create or look up the invoice in your system, get the outstanding amount, push a payment event to the terminal with the invoice ID as the reference, and process the webhook to mark the invoice as paid. The terminal event type is "invoice" with action "process". Include the invoice number in the data payload so you can match the webhook response to the correct invoice.
Invoice payments at a terminal follow a specific sequence that differs from ad-hoc retail charges.
The key difference from retail: In retail, you build the order in your app. For invoice payments, the order already exists as an invoice. Your app just needs to find it and push the right amount.
// invoice-lookup.js
async function lookupInvoice(searchTerm) {
// Search by invoice number, customer name, or phone
var result = await db.query(
'SELECT id, invoice_number, customer_name, total_amount, paid_amount, ' +
'(total_amount - paid_amount) as outstanding, status ' +
'FROM invoices ' +
'WHERE (invoice_number = $1 OR customer_phone = $1 OR customer_name ILIKE $2) ' +
'AND status != $3 ' +
'ORDER BY created_at DESC LIMIT 10',
[searchTerm, '%' + searchTerm + '%', 'paid']
);
return result.rows;
}
// Usage
var invoices = await lookupInvoice('INV-2026-0042');
// Returns: [{ id: 42, invoice_number: 'INV-2026-0042', customer_name: 'John Okafor',
// total_amount: 2500000, paid_amount: 0, outstanding: 2500000, status: 'unpaid' }]
Search flexibility. Customers do not always remember their invoice number. Let the cashier search by customer name or phone number too. Return all unpaid invoices for that customer so the cashier can select the right one.
Show the outstanding balance. If partial payments have been made, the outstanding balance may be less than the total. Always push the outstanding balance, not the original total.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
// push-invoice-payment.js
const axios = require('axios');
async function chargeInvoiceAtTerminal(invoiceId, terminalId) {
// Get invoice details
var invoice = await db.query(
'SELECT invoice_number, total_amount, paid_amount FROM invoices WHERE id = $1 AND status != $2',
[invoiceId, 'paid']
);
if (invoice.rows.length === 0) {
return { error: true, message: 'Invoice not found or already paid' };
}
var outstanding = invoice.rows[0].total_amount - invoice.rows[0].paid_amount;
var invoiceNumber = invoice.rows[0].invoice_number;
if (outstanding <= 0) {
return { error: true, message: 'Invoice has no outstanding balance' };
}
// Create a unique reference linking to the invoice
var reference = 'INV-' + invoiceNumber + '-' + Date.now().toString(36);
// Record the pending payment
await db.query(
'INSERT INTO invoice_payments (invoice_id, reference, amount, terminal_id, status, created_at) ' +
'VALUES ($1, $2, $3, $4, $5, NOW())',
[invoiceId, reference, outstanding, terminalId, 'pending']
);
// Push to terminal
await axios.post(
'https://api.paystack.co/terminal/' + terminalId + '/event',
{
type: 'invoice',
action: 'process',
data: {
id: reference,
amount: outstanding,
},
},
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
},
}
);
return { error: false, reference: reference, amount: outstanding };
}
Amount comes from the database, not the UI. The cashier sees the outstanding balance but does not type it. Your backend reads the amount from the invoice record. This prevents manual errors and price tampering.
// partial-payment.js
async function chargePartialInvoice(invoiceId, partialAmount, terminalId) {
var invoice = await db.query(
'SELECT invoice_number, total_amount, paid_amount FROM invoices WHERE id = $1',
[invoiceId]
);
var outstanding = invoice.rows[0].total_amount - invoice.rows[0].paid_amount;
if (partialAmount > outstanding) {
return { error: true, message: 'Partial amount exceeds outstanding balance' };
}
if (partialAmount <= 0) {
return { error: true, message: 'Amount must be positive' };
}
var reference = 'PINV-' + invoice.rows[0].invoice_number + '-' + Date.now().toString(36);
await db.query(
'INSERT INTO invoice_payments (invoice_id, reference, amount, terminal_id, status, is_partial, created_at) ' +
'VALUES ($1, $2, $3, $4, $5, true, NOW())',
[invoiceId, reference, partialAmount, terminalId, 'pending']
);
await pushPaymentToTerminal(terminalId, partialAmount, reference);
return {
error: false,
reference: reference,
amount_charged: partialAmount,
remaining_after: outstanding - partialAmount,
};
}
Track partial payments. Each partial payment creates a new invoice_payments record. The invoice's paid_amount is the sum of all successful payments. The invoice status changes to "paid" only when paid_amount equals total_amount.
Show payment history on the invoice. When the cashier looks up an invoice, show all previous payments: dates, amounts, and methods. This helps when a customer disputes how much they have paid.
// invoice-webhook.js
async function handleInvoicePaymentWebhook(webhookData) {
var reference = webhookData.data.reference;
var status = webhookData.data.status;
var amount = webhookData.data.amount;
// Find the pending invoice payment
var payment = await db.query(
'SELECT invoice_id, amount FROM invoice_payments WHERE reference = $1 AND status = $2',
[reference, 'pending']
);
if (payment.rows.length === 0) return;
var invoiceId = payment.rows[0].invoice_id;
if (status === 'success') {
// Update the payment record
await db.query(
'UPDATE invoice_payments SET status = $1, completed_at = NOW() WHERE reference = $2',
['completed', reference]
);
// Update the invoice paid amount
await db.query(
'UPDATE invoices SET paid_amount = paid_amount + $1, updated_at = NOW() WHERE id = $2',
[amount, invoiceId]
);
// Check if fully paid
var invoice = await db.query(
'SELECT total_amount, paid_amount FROM invoices WHERE id = $1',
[invoiceId]
);
if (invoice.rows[0].paid_amount >= invoice.rows[0].total_amount) {
await db.query(
'UPDATE invoices SET status = $1, paid_at = NOW() WHERE id = $2',
['paid', invoiceId]
);
}
} else {
await db.query(
'UPDATE invoice_payments SET status = $1, completed_at = NOW() WHERE reference = $2',
['failed', reference]
);
}
}
Amount verification. Compare the webhook amount with the amount you pushed to the terminal. They should match. If they do not, log the discrepancy and investigate.
After the payment completes, generate a receipt that ties the payment to the invoice.
// receipt.js
async function generateInvoiceReceipt(reference) {
var payment = await db.query(
'SELECT ip.amount, ip.completed_at, i.invoice_number, i.customer_name, ' +
'i.total_amount, i.paid_amount ' +
'FROM invoice_payments ip JOIN invoices i ON ip.invoice_id = i.id ' +
'WHERE ip.reference = $1',
[reference]
);
var data = payment.rows[0];
var remaining = data.total_amount - data.paid_amount;
return {
receipt_number: 'RCP-' + Date.now().toString(36),
invoice_number: data.invoice_number,
customer_name: data.customer_name,
amount_paid: data.amount,
payment_date: data.completed_at,
invoice_total: data.total_amount,
total_paid: data.paid_amount,
remaining_balance: remaining > 0 ? remaining : 0,
fully_settled: remaining <= 0,
};
}
Show remaining balance on the receipt. If this was a partial payment, the receipt should clearly show how much the customer still owes. This prevents disputes about outstanding balances.
For receipt printing integration, see the terminal webhooks and receipt printing guide.
At the end of each day, reconcile your invoice payments against Paystack transactions to catch any discrepancies.
// reconcile.js
async function dailyReconciliation(date) {
// Get all invoice payments for the day
var localPayments = await db.query(
'SELECT reference, amount, status FROM invoice_payments ' +
'WHERE created_at::date = $1',
[date]
);
var issues = [];
for (var i = 0; i < localPayments.rows.length; i++) {
var local = localPayments.rows[i];
if (local.status === 'pending') {
// Still pending after end of day - investigate
issues.push({
type: 'STUCK_PENDING',
reference: local.reference,
amount: local.amount,
});
}
}
return {
date: date,
total_payments: localPayments.rows.length,
issues: issues,
needs_attention: issues.length > 0,
};
}
Stuck pending payments. If an invoice payment is still in "pending" status at end of day, something went wrong. Either the webhook was lost, the terminal went offline, or the customer walked away. Investigate each one and update the status accordingly.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course