Lewati ke konten utama
Semua artikel
MahirStudi Kasus

Membangun POS Backend dari Nol

Studi kasus nyata: desain database, manajemen stok, transaksi, laporan, dan multi-outlet dari pengalaman klien UMKM.

16 menit baca
Bagikan

Instagram & WhatsApp Story tidak punya share-link langsung — unduh gambarnya, lalu unggah manual sebagai Story.


"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

  1. Kenapa POS Bukan Sekadar CRUD Produk
  2. Desain Skema: Produk, Stok per Outlet, dan Ledger
  3. Transaksi Penjualan Tanpa Kehilangan Stok
  4. Transfer Stok Antar-Outlet
  5. Sesi Kasir dan Rekonsiliasi Kas
  6. Laporan yang Sering Diminta Owner
  7. Role-Based Access: Kasir, Admin Outlet, Owner
  8. 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);

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.

CHECK (quantity >= 0) di outlet_stock bukan detail kosmetik. Constraint ini jaring pengaman terakhir di level database — kalau ada bug di application code yang lolos mengurangi stok sampai negatif, database yang menolak, bukan cuma 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, sama-sama lolos pengecekan, dan sama-sama melakukan UPDATE — stok berakhir di -1 walau ada CHECK (karena UPDATE ... SET quantity = quantity - 1 tetap tereksekusi duluan sebelum constraint dicek di beberapa kondisi race tertentu, dan yang lebih pasti: dua orang customer sama-sama "berhasil" beli barang yang stoknya cuma tersisa 1).

SELECT ... FOR UPDATE: Kunci Baris Selama Transaksi

async function sellTransaction({ outletId, cashierId, shiftId, items }) {
  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)
       VALUES ($1, $2, $3, $4) RETURNING id`,
      [outletId, cashierId, shiftId, items.reduce((s, i) => s + i.qty * i.price, 0)]
    );
 
    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, transaksi kedua gagal dengan pesan yang jelas, bukan sama-sama "berhasil" menjual barang yang sama.

Urutkan productId sebelum 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_id sebelum 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_items bertumbuh ke ratusan ribu baris, kalau sales.created_at tidak diindeks. Tambahkan CREATE INDEX idx_sales_created_at ON sales (created_at); sejak awal — 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();
  };
}
PeranBisaTidak bisa
KasirBuka/tutup shift sendiri, buat transaksi penjualanLihat shift kasir lain, ubah harga produk, approve transfer stok
Admin OutletSemua akses kasir + kelola produk & stok di outletnya, approve transfer masuk/keluar outletnyaLihat laporan/data outlet lain
OwnerLihat 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 existing = await db.query(
    'SELECT id FROM sales WHERE idempotency_key=$1', [idempotencyKey]
  );
  if (existing.rows[0]) {
    return res.json({ saleId: existing.rows[0].id, duplicate: true });
  }
 
  const saleId = await sellTransaction({ outletId, shiftId, items, idempotencyKey });
  res.json({ saleId, duplicate: false });
});

idempotencyKey di-generate di sisi client (kasir app) sekali per percobaan transaksi, dan dikirim ulang persis sama kalau request di-retry. Kolom ini butuh UNIQUE constraint di database — itu yang benar-benar mencegah duplikasi, bukan cuma pengecekan di application code yang masih bisa kalah oleh race condition yang sama seperti Bab 3.

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).

Lanjutkan ke

Sistem Booking & Reservasi Backend
Integrasi Payment Gateway: Midtrans & StripeSegera