Connecting a POS Terminal to Your Inventory System
When you receive a charge.success webhook from a terminal transaction, extract the order items from your database (linked via the payment reference), and deduct each item quantity from your inventory table. Use database transactions to ensure the payment status update and inventory deduction happen atomically. Build low-stock alerts that trigger when items fall below a threshold.
Inventory Database Schema
-- inventory tables
CREATE TABLE products (
id SERIAL PRIMARY KEY,
sku TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
price BIGINT NOT NULL, -- in kobo
stock_quantity INTEGER NOT NULL DEFAULT 0,
low_stock_threshold INTEGER NOT NULL DEFAULT 10,
category TEXT,
barcode TEXT UNIQUE,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE inventory_movements (
id SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL REFERENCES products(id),
quantity_change INTEGER NOT NULL, -- negative for sales, positive for restocks
reason TEXT NOT NULL, -- 'sale', 'restock', 'refund', 'adjustment'
reference TEXT, -- order ID or restock batch
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
inventory_movements is your audit trail. Every stock change, whether from a sale, restock, refund, or manual adjustment, gets a row. This lets you trace exactly how stock levels arrived at their current value.
stock_quantity on the products table is the current count. It is updated by triggers or application code whenever a movement is recorded. Having it directly on the product row makes lookups fast.
Deducting Stock on Payment Confirmation
// inventory-deduction.js
async function deductInventoryForOrder(orderId) {
var client = await db.pool.connect();
try {
await client.query('BEGIN');
// Get order items
var order = await client.query(
'SELECT items FROM orders WHERE id = $1 AND status = $2',
[orderId, 'paid']
);
if (order.rows.length === 0) {
await client.query('ROLLBACK');
return;
}
var items = JSON.parse(order.rows[0].items);
for (var i = 0; i < items.length; i++) {
var item = items[i];
// Deduct stock
await client.query(
'UPDATE products SET stock_quantity = stock_quantity - $1, updated_at = NOW() WHERE id = $2',
[item.quantity, item.product_id]
);
// Record movement
await client.query(
'INSERT INTO inventory_movements (product_id, quantity_change, reason, reference) VALUES ($1, $2, $3, $4)',
[item.product_id, -item.quantity, 'sale', 'ORDER-' + orderId]
);
}
// Mark inventory deduction as complete
await client.query(
'UPDATE orders SET inventory_deducted = true WHERE id = $1',
[orderId]
);
await client.query('COMMIT');
// Check for low stock after deduction
await checkLowStock(items.map(function(i) { return i.product_id; }));
} catch (error) {
await client.query('ROLLBACK');
console.error('Inventory deduction failed for order ' + orderId + ': ' + error.message);
throw error;
} finally {
client.release();
}
}
Database transaction is critical. If you deduct stock for 3 items and the 3rd fails, you need to roll back all deductions. Partial deductions create inaccurate inventory.
Idempotency. The inventory_deducted flag prevents double deduction if the webhook fires twice or if you retry the deduction after a failure.
Low-Stock Alerts
// low-stock.js
async function checkLowStock(productIds) {
var lowStock = await db.query(
'SELECT id, name, stock_quantity, low_stock_threshold FROM products ' +
'WHERE id = ANY($1) AND stock_quantity <= low_stock_threshold AND active = true',
[productIds]
);
if (lowStock.rows.length > 0) {
var message = 'Low stock alert:\n';
lowStock.rows.forEach(function(product) {
message += product.name + ': ' + product.stock_quantity + ' remaining (threshold: ' + product.low_stock_threshold + ')\n';
});
await notifyManager(message);
}
}
// Check all products daily for anything that dropped below threshold
async function dailyStockCheck() {
var lowStock = await db.query(
'SELECT name, stock_quantity, low_stock_threshold FROM products ' +
'WHERE stock_quantity <= low_stock_threshold AND active = true ORDER BY stock_quantity ASC'
);
if (lowStock.rows.length > 0) {
await notifyManager('Daily stock report: ' + lowStock.rows.length + ' items below threshold.');
}
}
Restoring Inventory on Refunds
// refund-restore.js
async function restoreInventoryForRefund(orderId, refundedItems) {
var client = await db.pool.connect();
try {
await client.query('BEGIN');
for (var i = 0; i < refundedItems.length; i++) {
var item = refundedItems[i];
await client.query(
'UPDATE products SET stock_quantity = stock_quantity + $1, updated_at = NOW() WHERE id = $2',
[item.quantity, item.product_id]
);
await client.query(
'INSERT INTO inventory_movements (product_id, quantity_change, reason, reference) VALUES ($1, $2, $3, $4)',
[item.product_id, item.quantity, 'refund', 'REFUND-ORDER-' + orderId]
);
}
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
Not all refunds restore inventory. If the customer returns damaged goods, you might refund the money but not add the items back to sellable stock. Your refund flow should let the operator choose whether to restock.
Barcode Scanner Integration
Barcode scanners speed up both order building and inventory management. When a product is scanned, look it up by barcode and add it to the order.
// barcode-lookup.js
router.get('/api/products/barcode/:code', async function(req, res) {
var barcode = req.params.code;
var product = await db.query(
'SELECT id, name, price, stock_quantity FROM products WHERE barcode = $1 AND active = true',
[barcode]
);
if (product.rows.length === 0) {
return res.status(404).json({ error: 'Product not found for barcode: ' + barcode });
}
var item = product.rows[0];
if (item.stock_quantity <= 0) {
return res.json({
product: item,
warning: 'This product is out of stock.',
});
}
return res.json({ product: item });
});
Out-of-stock handling. When a scanned product has zero stock, show a warning to the cashier. Let them decide whether to proceed (perhaps the stock count is wrong) or skip the item.
For the full barcode scanning implementation, see the retail checkout guide.
Inventory Reconciliation
// reconciliation.js
async function reconcileInventory(productId, physicalCount) {
var product = await db.query(
'SELECT stock_quantity FROM products WHERE id = $1',
[productId]
);
var systemCount = product.rows[0].stock_quantity;
var difference = physicalCount - systemCount;
if (difference !== 0) {
// Record the adjustment
await db.query(
'UPDATE products SET stock_quantity = $1, updated_at = NOW() WHERE id = $2',
[physicalCount, productId]
);
await db.query(
'INSERT INTO inventory_movements (product_id, quantity_change, reason, reference) VALUES ($1, $2, $3, $4)',
[productId, difference, 'adjustment', 'RECON-' + new Date().toISOString().slice(0, 10)]
);
}
return {
product_id: productId,
system_count: systemCount,
physical_count: physicalCount,
difference: difference,
adjusted: difference !== 0,
};
}
Reconcile weekly. Physical counts should happen regularly. The difference between system count and physical count reveals theft, damage, missed scans, or software bugs. Track the difference percentage over time. If it grows, investigate the cause.
Key Takeaways
- ✓Deduct inventory on webhook confirmation, not when the order is created. The customer has not paid until the webhook confirms the charge.
- ✓Use database transactions to update payment status and inventory atomically. If either fails, roll back both.
- ✓Track inventory by SKU or product ID. Link order line items to inventory records for automatic deduction.
- ✓Build low-stock alerts. When a product drops below a threshold, notify the store manager via email or push notification.
- ✓Reconcile inventory counts against terminal transaction history weekly to catch drift between actual stock and system records.
- ✓Handle refunds by restoring inventory. When you process a Paystack refund, add the refunded items back to stock.
Frequently Asked Questions
- Should I deduct inventory when the order is created or when the payment is confirmed?
- Deduct on payment confirmation (webhook). If you deduct when the order is created and the customer does not pay, your inventory shows less stock than you actually have. Only commit the stock reduction when money has been received.
- How do I handle items with variants (size, colour)?
- Create a separate product record (and SKU) for each variant. A "T-Shirt Size M Blue" and "T-Shirt Size L Red" are two different inventory items. Each has its own stock count and barcode.
- What if the inventory deduction fails after the payment succeeds?
- Log the failure and alert the operations team. The payment has already been processed, so you cannot undo it. Retry the inventory deduction. If retries fail, manually adjust the inventory. This scenario is rare with proper database transactions.
- Can I reserve stock when the order is created to prevent overselling?
- Yes. When the cashier adds items to the order, mark those quantities as "reserved" in a separate column. If the payment completes, convert the reservation to a deduction. If the payment fails or times out, release the reservation. This prevents two cashiers from selling the last unit simultaneously.
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