Build an ordering system with open tabs: orders start when the customer sits down and grow as items are added. When the customer is ready to pay, the server closes the tab, optionally adds a tip, and pushes the total to the Paystack Terminal. For split bills, calculate each person's portion and push separate terminal payments for each. The backend manages tab state while the terminal handles payment collection.
In retail, the customer picks items, pays, and leaves. The order is complete before payment. In a restaurant, the order is open for the entire duration of the meal.
Open tabs. The customer sits down, and a tab opens. Over the next 30 to 90 minutes, items are added: appetisers, drinks, entrees, desserts. The total changes multiple times before the customer asks for the bill.
Payment comes last. Unlike retail where payment is immediate, restaurant payment happens after the service is complete. This means your system must track open tabs, display the running total, and only push to the terminal when the customer requests the bill.
Split bills. A table of 4 people might want to pay separately. Your system needs to divide the bill and process multiple terminal payments for the same table.
Tips. In many markets, customers add a tip. Your system needs to handle this: either adding it to the bill before the terminal charge, or processing it separately.
// restaurant-tables.js
async function openTab(tableNumber, serverId) {
var tab = await db.query(
'INSERT INTO tabs (table_number, server_id, status, opened_at) ' +
'VALUES ($1, $2, $3, NOW()) RETURNING id',
[tableNumber, serverId, 'open']
);
await db.query(
'UPDATE tables SET status = $1, current_tab_id = $2 WHERE table_number = $3',
['occupied', tab.rows[0].id, tableNumber]
);
return { tabId: tab.rows[0].id };
}
async function addItemToTab(tabId, productId, quantity, notes) {
var product = await db.query('SELECT name, price FROM products WHERE id = $1', [productId]);
var price = product.rows[0].price;
await db.query(
'INSERT INTO tab_items (tab_id, product_id, product_name, price, quantity, notes, status, added_at) ' +
'VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())',
[tabId, productId, product.rows[0].name, price, quantity, notes || '', 'pending']
);
// Send to kitchen display
await sendToKitchen(tabId, productId, quantity, notes);
// Recalculate tab total
var total = await db.query(
'SELECT SUM(price * quantity) as total FROM tab_items WHERE tab_id = $1',
[tabId]
);
return { total: parseInt(total.rows[0].total) };
}
async function requestBill(tabId) {
var total = await db.query(
'SELECT SUM(price * quantity) as total FROM tab_items WHERE tab_id = $1',
[tabId]
);
await db.query(
'UPDATE tabs SET status = $1, total_amount = $2 WHERE id = $3',
['bill_requested', parseInt(total.rows[0].total), tabId]
);
return { tabId: tabId, total: parseInt(total.rows[0].total) };
}
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
// split-bill.js
async function splitBillEqually(tabId, numberOfPeople) {
var tab = await db.query('SELECT total_amount FROM tabs WHERE id = $1', [tabId]);
var total = tab.rows[0].total_amount;
var perPerson = Math.ceil(total / numberOfPeople); // Round up to avoid underpayment
var splits = [];
var remaining = total;
for (var i = 0; i < numberOfPeople; i++) {
var amount = (i === numberOfPeople - 1) ? remaining : perPerson;
splits.push({ person: i + 1, amount: amount });
remaining -= perPerson;
}
return splits;
}
async function processTabSplit(tabId, splits, terminalId) {
var results = [];
for (var i = 0; i < splits.length; i++) {
var split = splits[i];
var reference = 'TAB-' + tabId + '-SPLIT-' + (i + 1) + '-' + Date.now().toString(36);
await db.query(
'INSERT INTO tab_payments (tab_id, split_number, amount, reference, status) ' +
'VALUES ($1, $2, $3, $4, $5)',
[tabId, i + 1, split.amount, reference, 'pending']
);
// Push to terminal - wait for each payment before starting the next
await pushPaymentToTerminal(terminalId, split.amount, reference);
// Wait for webhook... (in practice, this is handled asynchronously)
results.push({ person: i + 1, amount: split.amount, reference: reference });
}
return results;
}
Process splits sequentially. The terminal can only handle one payment at a time. Push the first person's payment, wait for the webhook, then push the second person's payment. Show the server: "Person 1 of 4: tap card now."
Handle partial completion. If 3 of 4 people pay but the 4th card is declined, your system needs to track which splits are paid and which are not. Do not close the tab until all splits are settled.
// tips.js
async function addTipToTab(tabId, tipAmount) {
await db.query(
'UPDATE tabs SET tip_amount = $1, total_with_tip = total_amount + $1 WHERE id = $2',
[tipAmount, tabId]
);
var tab = await db.query('SELECT total_with_tip FROM tabs WHERE id = $1', [tabId]);
return { totalWithTip: tab.rows[0].total_with_tip };
}
async function chargeTabWithTip(tabId, terminalId) {
var tab = await db.query(
'SELECT total_amount, tip_amount, COALESCE(total_with_tip, total_amount) as charge_amount FROM tabs WHERE id = $1',
[tabId]
);
var chargeAmount = tab.rows[0].charge_amount;
var reference = 'TAB-' + tabId + '-' + Date.now().toString(36);
await pushPaymentToTerminal(terminalId, chargeAmount, reference);
return { reference: reference, amount: chargeAmount };
}
Tip before or after payment? In some restaurants, the customer writes the tip on the receipt after the card is charged (post-auth tip adjustment). This is more complex and requires card preauthorization. The simpler approach: ask for the tip before pushing to the terminal. The server enters the tip on the POS app, the system adds it to the total, and the combined amount is charged in one transaction.
Tip reporting. Track tips separately from the base bill for accounting. Tips may have different tax treatment than service charges. Report tips per server for payroll purposes.
When a server adds items to a tab, those items should appear on the kitchen display so the cooks can prepare them. This is separate from the payment flow but part of the same system.
// kitchen-display.js
async function sendToKitchen(tabId, productId, quantity, notes) {
var tab = await db.query('SELECT table_number FROM tabs WHERE id = $1', [tabId]);
var product = await db.query('SELECT name, category FROM products WHERE id = $1', [productId]);
var kitchenOrder = {
table: tab.rows[0].table_number,
item: product.rows[0].name,
quantity: quantity,
notes: notes,
category: product.rows[0].category, // 'appetizer', 'entree', 'dessert', 'drink'
time: new Date().toISOString(),
};
// Broadcast to kitchen display screens via SSE or WebSocket
broadcastToKitchen(kitchenOrder);
}
// Mark item as ready (kitchen taps "done")
async function markItemReady(tabItemId) {
await db.query(
'UPDATE tab_items SET status = $1, ready_at = NOW() WHERE id = $2',
['ready', tabItemId]
);
// Notify server's device
// broadcastToServer(tabItemId, 'item_ready');
}
Kitchen display is not a payment feature. It is an operational feature. But it lives in the same POS system and shares the same tab data. Design your data model to support both workflows.
// close-tab.js
async function closeTab(tabId) {
// Verify all payments are complete
var unpaid = await db.query(
'SELECT COUNT(*) as count FROM tab_payments WHERE tab_id = $1 AND status != $2',
[tabId, 'completed']
);
if (parseInt(unpaid.rows[0].count) > 0) {
return { error: true, message: 'Not all payments are complete' };
}
// Close the tab
await db.query(
'UPDATE tabs SET status = $1, closed_at = NOW() WHERE id = $2',
['closed', tabId]
);
// Free the table
var tab = await db.query('SELECT table_number FROM tabs WHERE id = $1', [tabId]);
await db.query(
'UPDATE tables SET status = $1, current_tab_id = NULL WHERE table_number = $2',
['available', tab.rows[0].table_number]
);
return { error: false, message: 'Tab closed, table available' };
}
Table turnover tracking. Record how long each table was occupied (opened_at to closed_at). This helps restaurant managers optimize seating and estimate wait times.
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