Restaurant Ordering and Terminal Payment Flow
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.
How Restaurant POS Differs from Retail
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.
Table and Tab Management
// 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) };
}
Splitting the Bill
// 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.
Handling Tips
// 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.
Kitchen Display Integration
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.
Closing the Tab and Clearing the Table
// 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.
Key Takeaways
- ✓Restaurant orders are open tabs that grow over time. Items are added throughout the meal. The total is calculated at payment time, not order creation time.
- ✓Support bill splitting: split equally among N people, split by specific items per person, or custom amounts per person.
- ✓Handle tips by adding the tip amount to the tab total before pushing to the terminal, or by processing the tip as a separate charge.
- ✓Send orders to the kitchen display system (KDS) when items are added. Payment is a separate step that happens later.
- ✓Assign tables to servers. The server who manages the table is the one who processes the payment on their section's terminal.
- ✓Track table status: available, occupied, ordering, bill_requested, paying, cleared.
Frequently Asked Questions
- How do I handle a customer who wants to add items after requesting the bill?
- Allow it. The server adds items to the tab, which updates the total. If the payment has not been pushed to the terminal yet, simply recalculate. If it has been pushed, cancel the pending terminal payment, add the items, and push a new payment with the updated total.
- Can I use Paystack Terminal for pay-at-table (the terminal goes to the customer)?
- This depends on the terminal model. If the terminal is portable (battery-powered with wireless connectivity), the server can bring it to the table. Push the payment from your POS system and let the customer tap their card at the table. Not all terminal models support this portability.
- How do I handle service charges vs tips?
- A service charge is a mandatory fee added to the bill (common for large parties). Add it as a line item on the tab. A tip is voluntary and entered by the customer or server after the bill is presented. Track them separately in your database for accounting purposes.
- What if the terminal payment fails for one person in a split bill?
- Keep the tab open for the unpaid split. The other splits that were paid successfully remain paid. The server can retry the failed payment with a different card, or the customer can pay their portion in cash. Close the tab only when all splits are settled.
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