"Di REST API biasa, request yang gagal tinggal di-retry user. Di POS, request yang gagal berarti kasir berdiri di depan pembeli yang sudah antre, sambil layar menunjukkan error yang tidak dia mengerti."
Tentang Artikel Ini
Artikel-artikel sebelumnya membangun toko-api — backend untuk toko online. Studi kasus ini berbeda kliennya: bisnis retail fisik dengan beberapa cabang (outlet), yang butuh sistem kasir sendiri. Saya sengaja jadikan ini artikel terpisah dari toko-api, bukan variasi kecil — karena constraint dunia nyatanya memang beda jauh, walau skill dasarnya sama (REST API, desain database, autentikasi) yang sudah kamu pelajari di artikel-artikel sebelumnya.
Pola-pola di artikel ini saya susun dari beberapa project retail skala UMKM yang pernah saya kerjakan — bukan satu klien spesifik yang saya sebut namanya, tapi masalah yang sama konsisten muncul di hampir semua project sejenis: stok yang harus akurat sampai satuan (retail fisik tidak punya toleransi "kelebihan stok sedikit tidak apa-apa"), kasir yang menjalankan transaksi puluhan kali sehari dan tidak boleh menunggu, dan owner yang butuh laporan lintas-outlet yang akurat di akhir hari.
Bedanya dengan REST API biasa yang paling langsung berdampak ke desain:
- Konkurensi nyata, bukan teoretis. Dua kasir di outlet yang sama bisa menjual barang terakhir di detik yang sama — race condition di sini bukan edge case yang jarang terjadi, tapi kejadian harian.
- Stok adalah kebenaran finansial, bukan cuma angka display. Selisih stok berarti selisih uang yang harus dipertanggungjawabkan owner.
- Sistem harus punya jejak audit yang bisa dipercaya — siapa mengubah harga, siapa approve transfer stok, kapan, karena ini yang dipakai untuk investigasi kalau ada kejanggalan.
Setelah selesai, kamu akan bisa:
- Merancang skema database untuk stok multi-outlet yang akurat dan bisa diaudit
- Mencegah oversell lewat row locking saat transaksi konkuren
- Mengimplementasikan transfer stok antar-outlet yang atomik
- Membangun sesi kasir (shift) dengan rekonsiliasi kas
- Menyusun query laporan yang sering diminta owner tanpa membebani database
- Menerapkan role-based access untuk kasir, admin outlet, dan owner
- Menangani input yang perlu tahan terhadap koneksi internet yang tidak stabil
Prasyarat: Sudah menyelesaikan artikel REST API dengan Node.js & Express, Desain Database untuk Sistem Bisnis, dan Autentikasi JWT & OAuth2 — studi kasus ini menggabungkan ketiganya ke satu sistem nyata, bukan mengajarkan dari nol lagi.
Daftar Isi
- Kenapa POS Bukan Sekadar CRUD Produk
- Desain Skema: Produk, Stok per Outlet, dan Ledger
- Transaksi Penjualan Tanpa Kehilangan Stok
- Transfer Stok Antar-Outlet
- Sesi Kasir dan Rekonsiliasi Kas
- Laporan yang Sering Diminta Owner
- Role-Based Access: Kasir, Admin Outlet, Owner
- Yang Sering Diremehkan: Idempotency dan Audit Trail
Bab 1: Kenapa POS Bukan Sekadar CRUD Produk
Godaan pertama waktu bangun POS adalah memperlakukannya seperti toko-api yang sudah kamu bangun — tabel products, endpoint CRUD, selesai. Ini akan jalan mulus di demo, dan gagal total di outlet pertama yang benar-benar ramai.
Tiga hal yang membuat POS perlu dirancang berbeda sejak skema pertama:
Stok bukan satu angka global — stok itu per-outlet. Toko cabang A dan cabang B punya stok produk yang sama tapi jumlahnya berbeda, dan penjualan di satu outlet tidak boleh mengurangi stok outlet lain.
Transaksi harus tetap benar walau dua hal terjadi bersamaan. Kasir 1 dan kasir 2 di outlet yang sama bisa memindai barcode produk yang sama, stoknya tinggal 1, di detik yang persis sama. Sistem harus memastikan cuma satu yang berhasil, bukan dua-duanya sukses lalu stok jadi -1.
Setiap perubahan stok harus punya alasan yang tercatat. Stok berkurang karena dijual, karena ditransfer keluar, karena disesuaikan manual (barang rusak/hilang) — ketiganya harus bisa dibedakan setelahnya, bukan cuma "stok sekarang segini".
Bab-bab berikutnya dibangun untuk menjawab tiga hal ini secara langsung, bukan ditambal belakangan.
Bab 2: Desain Skema: Produk, Stok per Outlet, dan Ledger
Kenapa Bukan Satu Kolom stock di Tabel Produk
Pendekatan naif: tabel products punya kolom stock. Ini gagal begitu ada lebih dari satu outlet — dan bahkan untuk satu outlet, kolom tunggal yang di-UPDATE langsung tidak menyisakan jejak kenapa angkanya berubah. Solusinya: pisahkan stok saat ini (angka yang sering dibaca, perlu cepat) dari ledger pergerakan stok (riwayat lengkap, sumber kebenaran).
-- migrations/020_create_pos_schema.sql
CREATE TABLE outlets (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
address TEXT
);
CREATE TABLE products (
id SERIAL PRIMARY KEY,
sku VARCHAR(50) UNIQUE NOT NULL,
name VARCHAR(200) NOT NULL,
category VARCHAR(100),
base_price NUMERIC(12, 2) NOT NULL
);
-- Stok saat ini per outlet — dibaca terus-menerus saat transaksi, harus cepat
CREATE TABLE outlet_stock (
outlet_id INT REFERENCES outlets(id),
product_id INT REFERENCES products(id),
quantity INT NOT NULL DEFAULT 0 CHECK (quantity >= 0),
PRIMARY KEY (outlet_id, product_id)
);
-- Ledger — sumber kebenaran, tidak pernah di-UPDATE, cuma di-INSERT
CREATE TABLE stock_movements (
id SERIAL PRIMARY KEY,
outlet_id INT REFERENCES outlets(id),
product_id INT REFERENCES products(id),
movement_type VARCHAR(20) NOT NULL, -- 'sale' | 'restock' | 'transfer_in' | 'transfer_out' | 'adjustment'
quantity INT NOT NULL, -- negatif untuk pengurangan, positif untuk penambahan
reference_type VARCHAR(20), -- 'sale' | 'transfer' | 'manual'
reference_id INT,
note TEXT,
created_by INT REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_stock_movements_outlet_product ON stock_movements (outlet_id, product_id, created_at);
-- Transaksi penjualan. shift_id dibahas di Bab 5, idempotency_key di Bab 8 —
-- keduanya dimasukkan sejak awal karena menambahkannya belakangan ke tabel
-- yang sudah berisi transaksi production jauh lebih merepotkan.
CREATE TABLE sales (
id SERIAL PRIMARY KEY,
outlet_id INT NOT NULL REFERENCES outlets(id),
cashier_id INT NOT NULL REFERENCES users(id),
shift_id INT REFERENCES cashier_shifts(id),
total NUMERIC(12, 2) NOT NULL,
payment_method VARCHAR(20) NOT NULL DEFAULT 'cash', -- 'cash' | 'card' | 'transfer' | 'qris'
idempotency_key VARCHAR(100) UNIQUE, -- UNIQUE inilah yang mencegah transaksi ganda
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Harga disalin ke price_at_sale, bukan dibaca dari products saat laporan dibuat —
-- kalau harga produk berubah besok, struk dan omzet kemarin tidak boleh ikut berubah.
CREATE TABLE sale_items (
sale_id INT REFERENCES sales(id),
product_id INT REFERENCES products(id),
qty INT NOT NULL CHECK (qty > 0),
price_at_sale NUMERIC(12, 2) NOT NULL,
PRIMARY KEY (sale_id, product_id)
);
CREATE INDEX idx_sales_created_at ON sales (created_at);
CREATE INDEX idx_sales_shift ON sales (shift_id);
cashier_shiftsdibuat lebih dulu darisaleskarenasales.shift_idmereferensikannya — DDL lengkapnya ada di Bab 5. Kalau kamu menjalankan migration ini urut dari atas, pindahkan blokcashier_shiftske sini.
outlet_stock.quantity adalah angka yang selalu bisa direkonstruksi ulang dari SUM(quantity) di stock_movements untuk kombinasi outlet+produk yang sama — kalau suatu hari kedua angka ini tidak cocok, itu tanda ada bug atau transaksi yang lolos tanpa lewat jalur resmi. Simpan keduanya (bukan cuma ledger dan hitung SUM tiap kali) karena outlet_stock dibaca jauh lebih sering daripada ditulis — kasir mengecek stok sebelum tiap transaksi, dan SUM() atas ribuan baris ledger tiap kali terlalu mahal untuk operasi sesering itu.
Jangan tergoda melepas
CHECK (quantity >= 0)darioutlet_stockkarena merasa application code sudah menjaganya. Constraint inilah jaring pengaman terakhir di level database: kalau suatu hari ada jalur kode yang lolos mengurangi stok sampai negatif, yang menolak adalah database — bukan logic aplikasi yang (mungkin) benar.
Bab 3: Transaksi Penjualan Tanpa Kehilangan Stok
Kenapa Endpoint Sederhana Tidak Aman untuk Konkurensi
// JANGAN — race condition
async function sellNaive(outletId, productId, qty) {
const { rows } = await db.query(
'SELECT quantity FROM outlet_stock WHERE outlet_id=$1 AND product_id=$2',
[outletId, productId]
);
if (rows[0].quantity < qty) throw new Error('Stok tidak cukup');
await db.query(
'UPDATE outlet_stock SET quantity = quantity - $1 WHERE outlet_id=$2 AND product_id=$3',
[qty, outletId, productId]
);
}Antara SELECT dan UPDATE di atas, ada celah waktu. Kalau dua kasir menjalankan fungsi ini bersamaan saat stok tinggal 1, keduanya bisa sama-sama membaca quantity: 1 dan sama-sama lolos pengecekan if — karena pengecekan itu dilakukan atas angka yang sudah basi begitu kasir lain menyelesaikan transaksinya.
Yang terjadi berikutnya sering disalahpahami, jadi perlu dijelaskan persis. Stok tidak berakhir di -1: CHECK (quantity >= 0) dicek ulang di setiap UPDATE, dan di level READ COMMITTED PostgreSQL menyerialkan dua UPDATE ke baris yang sama — yang kedua menghitung ulang quantity - 1 dari angka hasil commit yang pertama (0), dapat -1, lalu ditolak. Database tetap konsisten; itu memang tugas constraint-nya, dan di situlah nilainya.
Masalahnya ada di tempat lain, dan tetap serius:
- Pengecekan
SELECT-nya praktis tidak berguna. Yang benar-benar menolak oversell adalah constraint, bukanifdi baris 5. Validasi yang kelihatannya menjaga sesuatu padahal tidak adalah jenis kode yang paling menyesatkan saat dibaca ulang enam bulan kemudian. - Kasir kedua dapat error mentah, bukan pesan yang bisa ditindaklanjuti. Yang keluar adalah constraint violation dari driver database (
23514), yang kalau tidak ditangani jadi HTTP 500 — bukan "Stok tidak cukup". Di depan antrean, bedanya besar. - Tidak ada transaksi sama sekali. Untuk keranjang berisi beberapa item, item-item yang sempat lolos sebelum satu item gagal tetap terpotong stoknya, dan tidak ada yang mengembalikannya.
Jadi pertanyaannya bukan "bagaimana supaya stok tidak minus" — itu sudah dijaga. Pertanyaannya: bagaimana supaya kegagalan terdeteksi lebih awal, dengan pesan yang jelas, dan tanpa menyisakan perubahan setengah jadi.
SELECT ... FOR UPDATE: Kunci Baris Selama Transaksi
// idempotencyKey boleh null untuk sekarang — perannya dibahas di Bab 8.
async function sellTransaction({ outletId, cashierId, shiftId, items, idempotencyKey = null }) {
const client = await db.connect();
try {
await client.query('BEGIN');
for (const item of items) {
// Kunci baris outlet_stock ini — transaksi lain yang mencoba SELECT FOR UPDATE
// baris yang sama akan MENUNGGU sampai transaksi ini selesai (COMMIT/ROLLBACK).
const { rows } = await client.query(
`SELECT quantity FROM outlet_stock
WHERE outlet_id=$1 AND product_id=$2 FOR UPDATE`,
[outletId, item.productId]
);
if (!rows[0] || rows[0].quantity < item.qty) {
throw new Error(`Stok ${item.productId} tidak cukup`);
}
}
const sale = await client.query(
`INSERT INTO sales (outlet_id, cashier_id, shift_id, total, idempotency_key)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
[
outletId, cashierId, shiftId,
items.reduce((s, i) => s + i.qty * i.price, 0),
idempotencyKey,
]
);
for (const item of items) {
await client.query(
`UPDATE outlet_stock SET quantity = quantity - $1
WHERE outlet_id=$2 AND product_id=$3`,
[item.qty, outletId, item.productId]
);
await client.query(
`INSERT INTO stock_movements (outlet_id, product_id, movement_type, quantity, reference_type, reference_id, created_by)
VALUES ($1, $2, 'sale', $3, 'sale', $4, $5)`,
[outletId, item.productId, -item.qty, sale.rows[0].id, cashierId]
);
await client.query(
`INSERT INTO sale_items (sale_id, product_id, qty, price_at_sale)
VALUES ($1, $2, $3, $4)`,
[sale.rows[0].id, item.productId, item.qty, item.price]
);
}
await client.query('COMMIT');
return sale.rows[0].id;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}FOR UPDATE membuat transaksi kedua yang mencoba mengunci baris outlet_stock yang sama menunggu, bukan membaca data basi. Begitu transaksi pertama COMMIT (stok sudah berkurang), transaksi kedua baru lanjut membaca — dan kalau stoknya sudah habis, pengecekan if-nya kali ini benar-benar berarti: transaksi gagal lewat throw dengan pesan "Stok tidak cukup" sebelum satu baris pun diubah, lalu ROLLBACK membereskan sisanya. Bandingkan dengan versi naif di atas, yang kegagalannya baru muncul sebagai constraint violation mentah setelah sebagian item terlanjur terpotong.
Urutkan
productIdsebelum melakukan locking kalau satu transaksi bisa melibatkan banyak produk sekaligus (keranjang belanja beberapa item). Tanpa urutan yang konsisten, dua transaksi yang mengunci produk A dan B dengan urutan berbeda bisa saling menunggu selamanya — deadlock.ORDER BY product_idsebelum loop locking di atas menghilangkan risiko ini.
Bab 4: Transfer Stok Antar-Outlet
Outlet A kehabisan stok, outlet B kelebihan — transfer manual antar-cabang adalah operasi harian di retail multi-outlet. Ini butuh dua pergerakan stok yang harus sama-sama berhasil atau sama-sama gagal: kurangi dari outlet asal, tambah ke outlet tujuan.
CREATE TABLE stock_transfers (
id SERIAL PRIMARY KEY,
from_outlet_id INT REFERENCES outlets(id),
to_outlet_id INT REFERENCES outlets(id),
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- 'pending' | 'completed' | 'cancelled'
requested_by INT REFERENCES users(id),
completed_by INT REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE TABLE stock_transfer_items (
transfer_id INT REFERENCES stock_transfers(id),
product_id INT REFERENCES products(id),
qty INT NOT NULL,
PRIMARY KEY (transfer_id, product_id)
);async function completeTransfer(transferId, completedByUserId) {
const client = await db.connect();
try {
await client.query('BEGIN');
const transfer = await client.query(
`SELECT * FROM stock_transfers WHERE id=$1 AND status='pending' FOR UPDATE`,
[transferId]
);
if (!transfer.rows[0]) throw new Error('Transfer tidak ditemukan atau sudah diproses');
const items = await client.query(
'SELECT product_id, qty FROM stock_transfer_items WHERE transfer_id=$1 ORDER BY product_id',
[transferId]
);
for (const item of items.rows) {
// Kurangi dari outlet asal — validasi stok cukup, sama seperti Bab 3
const stock = await client.query(
`SELECT quantity FROM outlet_stock WHERE outlet_id=$1 AND product_id=$2 FOR UPDATE`,
[transfer.rows[0].from_outlet_id, item.product_id]
);
if (!stock.rows[0] || stock.rows[0].quantity < item.qty) {
throw new Error(`Stok outlet asal tidak cukup untuk produk ${item.product_id}`);
}
await client.query(
`UPDATE outlet_stock SET quantity = quantity - $1 WHERE outlet_id=$2 AND product_id=$3`,
[item.qty, transfer.rows[0].from_outlet_id, item.product_id]
);
await client.query(
`INSERT INTO stock_movements (outlet_id, product_id, movement_type, quantity, reference_type, reference_id, created_by)
VALUES ($1, $2, 'transfer_out', $3, 'transfer', $4, $5)`,
[transfer.rows[0].from_outlet_id, item.product_id, -item.qty, transferId, completedByUserId]
);
// Tambah ke outlet tujuan — upsert karena outlet tujuan mungkin belum pernah stok produk ini
await client.query(
`INSERT INTO outlet_stock (outlet_id, product_id, quantity) VALUES ($1, $2, $3)
ON CONFLICT (outlet_id, product_id) DO UPDATE SET quantity = outlet_stock.quantity + $3`,
[transfer.rows[0].to_outlet_id, item.product_id, item.qty]
);
await client.query(
`INSERT INTO stock_movements (outlet_id, product_id, movement_type, quantity, reference_type, reference_id, created_by)
VALUES ($1, $2, 'transfer_in', $3, 'transfer', $4, $5)`,
[transfer.rows[0].to_outlet_id, item.product_id, item.qty, transferId, completedByUserId]
);
}
await client.query(
`UPDATE stock_transfers SET status='completed', completed_by=$1, completed_at=NOW() WHERE id=$2`,
[completedByUserId, transferId]
);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}Pola ini sama persis dengan Bab 3 — locking, validasi, catat ke ledger — cuma dikalikan dua sisi (asal dan tujuan) dalam satu transaksi database yang sama. Kalau langkah manapun gagal di tengah, ROLLBACK membatalkan seluruhnya; tidak ada kondisi di mana stok sudah berkurang di outlet asal tapi belum bertambah di outlet tujuan.
Bab 5: Sesi Kasir dan Rekonsiliasi Kas
Kenapa Transaksi Perlu Terikat ke Shift, Bukan Cuma ke Kasir
Kasir bekerja dalam shift — buka kasir dengan modal awal, tutup kasir di akhir dengan uang yang seharusnya cocok dengan total penjualan selama shift itu. Mengikat tiap transaksi ke shift_id (bukan cuma cashier_id) membuat rekonsiliasi per-shift bisa dilakukan tanpa harus memilah berdasarkan rentang waktu yang rawan salah potong.
CREATE TABLE cashier_shifts (
id SERIAL PRIMARY KEY,
outlet_id INT REFERENCES outlets(id),
user_id INT REFERENCES users(id),
opening_cash NUMERIC(12, 2) NOT NULL,
expected_closing_cash NUMERIC(12, 2),
actual_closing_cash NUMERIC(12, 2),
cash_difference NUMERIC(12, 2),
opened_at TIMESTAMPTZ DEFAULT NOW(),
closed_at TIMESTAMPTZ
);router.post('/shifts/:id/close', requireRole(['kasir', 'admin']), async (req, res, next) => {
const { actualClosingCash } = req.body;
const shift = await getShiftById(req.params.id);
const { rows } = await db.query(
`SELECT COALESCE(SUM(total), 0) AS cash_sales
FROM sales WHERE shift_id=$1 AND payment_method='cash'`,
[shift.id]
);
const expectedClosingCash = Number(shift.opening_cash) + Number(rows[0].cash_sales);
const difference = Number(actualClosingCash) - expectedClosingCash;
await db.query(
`UPDATE cashier_shifts
SET expected_closing_cash=$1, actual_closing_cash=$2, cash_difference=$3, closed_at=NOW()
WHERE id=$4`,
[expectedClosingCash, actualClosingCash, difference, shift.id]
);
res.json({ expectedClosingCash, actualClosingCash, difference });
});cash_difference selalu dicatat, tidak pernah disembunyikan — baik hasilnya nol, positif, atau negatif. Selisih kas adalah informasi, bukan aib yang perlu ditutupi di level sistem; keputusan apa yang dilakukan dengan selisih itu (toleransi kecil vs investigasi) adalah kebijakan bisnis owner, bukan sesuatu yang backend putuskan sendiri dengan diam-diam membulatkan angka.
Bab 6: Laporan yang Sering Diminta Owner
Laporan Harian per Outlet
SELECT
o.name AS outlet,
DATE(s.created_at) AS tanggal,
COUNT(*) AS jumlah_transaksi,
SUM(s.total) AS total_penjualan
FROM sales s
JOIN outlets o ON o.id = s.outlet_id
WHERE s.created_at >= CURRENT_DATE
GROUP BY o.name, DATE(s.created_at)
ORDER BY o.name;Produk Terlaris Lintas Outlet
SELECT
p.name,
SUM(si.qty) AS total_terjual,
SUM(si.qty * si.price_at_sale) AS total_omzet
FROM sale_items si
JOIN sales s ON s.id = si.sale_id
JOIN products p ON p.id = si.product_id
WHERE s.created_at >= NOW() - INTERVAL '30 days'
GROUP BY p.name
ORDER BY total_terjual DESC
LIMIT 20;Query "produk terlaris 30 hari" di atas akan melambat begitu tabel
sales/sale_itemsbertumbuh ke ratusan ribu baris, kalausales.created_attidak diindeks. Itu alasanidx_sales_created_atsudah ikut dibuat di skema Bab 2, bukan ditambahkan nanti — laporan yang lambat di jam sibuk (biasanya owner justru mengecek laporan di jam yang sama dengan jam ramai transaksi) adalah keluhan yang sering muncul telat, setelah datanya sudah besar dan index baru terasa menyakitkan untuk dibangun di tabel production yang sedang dipakai.
Kenapa Laporan Tidak Query Langsung ke stock_movements yang Mentah
Owner biasanya bertanya "berapa omzet outlet A minggu ini", bukan "tampilkan semua baris ledger". Godaan untuk membuat semua laporan sebagai query ad-hoc langsung ke tabel mentah akan berhenti scalable begitu jumlah outlet dan volume transaksi bertambah. Untuk laporan yang sering diakses (ringkasan harian, misalnya), pertimbangkan tabel ringkasan yang di-generate oleh background job semalam (pola job yang sama dari artikel Message Queue & Background Jobs) — laporan dibaca dari tabel yang sudah diringkas, bukan dihitung ulang dari jutaan baris tiap kali owner membuka dashboard.
Bab 7: Role-Based Access: Kasir, Admin Outlet, Owner
Tiga peran dengan cakupan akses yang jelas berbeda — pola ini melanjutkan langsung dari artikel Autentikasi JWT & OAuth2:
// src/middleware/requireRole.js
export function requireRole(allowedRoles) {
return (req, res, next) => {
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({ error: 'Tidak punya akses untuk aksi ini' });
}
next();
};
}| Peran | Bisa | Tidak bisa |
|---|---|---|
| Kasir | Buka/tutup shift sendiri, buat transaksi penjualan | Lihat shift kasir lain, ubah harga produk, approve transfer stok |
| Admin Outlet | Semua akses kasir + kelola produk & stok di outletnya, approve transfer masuk/keluar outletnya | Lihat laporan/data outlet lain |
| Owner | Lihat semua outlet, semua laporan, semua shift | (Biasanya tidak melakukan transaksi harian, tapi tidak dibatasi teknis — kebijakan bisnis, bukan constraint sistem) |
router.get('/outlets/:outletId/reports/daily', requireRole(['admin', 'owner']), async (req, res, next) => {
// Admin outlet cuma boleh lihat outlet miliknya sendiri — owner boleh semua
if (req.user.role === 'admin' && req.user.outletId !== Number(req.params.outletId)) {
return res.status(403).json({ error: 'Tidak punya akses ke outlet ini' });
}
// ...query laporan
});Perhatikan baris pengecekan req.user.outletId !== Number(req.params.outletId) — requireRole saja tidak cukup untuk admin outlet, karena dua admin dari outlet berbeda sama-sama punya role 'admin'. Otorisasi berbasis role menjawab "apa yang boleh dilakukan peran ini", tapi otorisasi berbasis kepemilikan data ("outlet siapa") tetap perlu dicek terpisah di level data, bukan cuma di level role.
Bab 8: Yang Sering Diremehkan: Idempotency dan Audit Trail
Retry Aman Saat Koneksi Retail Kadang Putus
Koneksi internet di toko fisik tidak seandal server cloud. Kasir menekan "bayar", koneksi putus sebelum response sampai, kasir menekan lagi — tanpa penanganan, ini bisa membuat transaksi yang sama tercatat dua kali dan stok berkurang dua kali untuk satu penjualan yang sama.
router.post('/sales', async (req, res, next) => {
const { idempotencyKey, items, outletId, shiftId } = req.body;
const cashierId = req.user.id;
try {
const saleId = await sellTransaction({
outletId, cashierId, shiftId, items, idempotencyKey,
});
return res.json({ saleId, duplicate: false });
} catch (err) {
if (err.code !== '23505') throw err; // 23505 = unique_violation
// Kunci yang sama sudah pernah masuk: ini retry, bukan transaksi baru.
const { rows } = await db.query(
'SELECT id FROM sales WHERE idempotency_key=$1', [idempotencyKey]
);
return res.json({ saleId: rows[0].id, duplicate: true });
}
});idempotencyKey di-generate di sisi client (kasir app) sekali per percobaan transaksi, dan dikirim ulang persis sama kalau request di-retry. sellTransaction meneruskannya ke kolom idempotency_key saat INSERT INTO sales.
Perhatikan urutannya: coba dulu, tangani benturannya kalau terjadi — bukan SELECT dulu untuk mengecek apakah kunci sudah ada, baru INSERT. Pola cek-dulu itu punya celah yang persis sama seperti Bab 3: dua request retry yang datang bersamaan sama-sama mendapati kunci belum ada, sama-sama lanjut, dan transaksinya tercatat dua kali. UNIQUE constraint di database yang benar-benar menutup celah itu, karena dialah satu-satunya pihak yang bisa memutuskan siapa yang lebih dulu. Tugas application code tinggal menerjemahkan penolakan itu (23505) jadi response yang benar — mengembalikan saleId yang sudah ada, bukan error ke kasir yang cuma menekan tombol bayar dua kali karena koneksinya lambat.
Audit Trail: Siapa Mengubah Apa
CREATE TABLE audit_log (
id SERIAL PRIMARY KEY,
actor_id INT REFERENCES users(id),
action VARCHAR(50) NOT NULL, -- 'price_change' | 'stock_adjustment' | 'transfer_approval' | dll
entity_type VARCHAR(50),
entity_id INT,
before_value JSONB,
after_value JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);Perubahan harga produk dan penyesuaian stok manual (movement_type = 'adjustment' di Bab 2) adalah dua aksi yang paling sering jadi sumber pertanyaan owner belakangan — "kenapa harga ini berubah", "kenapa stok disesuaikan minus 5". Catat before_value/after_value di momen perubahan terjadi, bukan berharap bisa direkonstruksi dari log aplikasi generik nanti. Ini bukan soal tidak percaya tim — ini soal ketika ada pertanyaan (dan akan selalu ada), jawabannya sudah tersedia, bukan perlu ditebak.
Penutup
Sistem POS ini menggabungkan hampir semua yang dibahas di artikel-artikel sebelumnya — REST API, desain database, autentikasi — tapi disatukan dengan constraint yang cuma muncul begitu sistemnya benar-benar dipakai untuk transaksi uang sungguhan, berkali-kali sehari, oleh orang yang tidak bisa menunggu error message di-debug. Kalau ada satu pelajaran yang paling sering saya lihat gagal dipahami di project sejenis, itu bukan bagian teknis lockingnya — tapi keputusan untuk selalu mencatat kenapa (ledger, audit log, cash difference), bukan cuma apa angka akhirnya. Angka akhir yang salah bisa diperbaiki kalau ada jejaknya; angka akhir yang salah tanpa jejak cuma bisa ditebak-tebak.
Arsitektur Final
Kasir (kirim idempotencyKey per transaksi)
│
▼ POST /sales
requireRole(['kasir','admin']) ──▶ sellTransaction()
│
├─▶ SELECT ... FOR UPDATE (kunci outlet_stock, urutan product_id konsisten)
├─▶ INSERT sales + sale_items
├─▶ UPDATE outlet_stock
└─▶ INSERT stock_movements (ledger, tidak pernah di-UPDATE)
Transfer antar-outlet: pola sama, dua sisi (from/to) dalam satu transaksi
Shift kasir: opening_cash + cash_sales = expected_closing_cash (selisih selalu dicatat)
Laporan harian: dari tabel ringkasan (background job), bukan query mentah tiap request
Production Checklist
Langkah selanjutnya: Dua artikel studi kasus lain melanjutkan pola yang sama ke domain berbeda — Sistem Booking & Reservasi Backend (slot management dan conflict detection, tantangan konkurensi yang mirip Bab 3 tapi untuk jadwal, bukan stok) dan Integrasi Payment Gateway (begitu POS ini perlu terima pembayaran non-tunai secara langsung, bukan cuma dicatat manual).