Ringkasan Utama
Sesi kas POS adalah catatan per-shift: kasir membuka dengan modal awal yang dihitung, setiap penjualan tunai dan pengeluaran dicatat, dan saat tutup sistem menghitung kas ekspektasi sebagai modal awal ditambah kas masuk dikurangi kas keluar. Kasir menghitung laci fisik, selisihnya adalah varians, dan kedua angka plus siapa yang menutup ditulis ke log audit append-only.
Ketika saya membangun JID Carwash ERP untuk sebuah UKM carwash Indonesia, satu fitur yang paling membuat pemilik cemas bukanlah penggajian atau inventaris. Melainkan laci kas. Setiap shift, kasir menangani setumpuk uang kertas fisik, dan di akhir hari pemilik ingin satu jawaban jujur: apakah uang di laci cocok dengan yang seharusnya menurut sistem? Mendapatkan jawaban itu secara andal adalah keseluruhan tugas modul sesi kas.
Godaannya adalah menyimpan satu saldo kas berjalan tunggal dan menyesuaikannya di setiap transaksi. Saya belajar dengan cara sulit bahwa ini model yang salah. Saldo berjalan menyembunyikan riwayat, tidak bisa menjawab siapa yang bertugas ketika selisih muncul, dan mengubah bug konkurensi menjadi uang hilang. Sebagai gantinya saya memodelkan sesi kas sebagai catatan eksplisit yang tidak-bisa-diubah-setelah-ditutup, dan saya membuat buku kas sebagai tampilan turunan alih-alih tabel yang bisa ditulisi siapa pun.
Alur kerja kasir nyata di carwash
Sebelum menulis satu model Prisma pun saya duduk di kasir dan mengamati. Carwash ini menjalankan dua shift sehari di beberapa cabang. Sebuah shift dimulai ketika kasir menghitung laci dan mencatat modal awal, biasanya sekitar Rp 500.000 dalam pecahan kecil untuk kembalian. Sepanjang hari uang tunai masuk dari order cuci dan keluar untuk pengeluaran kecil seperti membeli detergen atau membayar kasbon, uang muka karyawan. Di akhir shift kasir menghitung laci lagi, menyerahkan ke orang berikutnya, dan uang di atas modal disetorkan.
- Buka: kasir menghitung laci secara fisik dan mencatat modal awal untuk shift.
- Selama: kas masuk dari penjualan tunai yang dibayar, kas keluar untuk pengeluaran kecil dan pencairan kasbon, masing-masing dicatat sebagai pergerakan tersendiri.
- Tutup: kasir menghitung laci lagi, sistem menampilkan kas ekspektasi, dan selisihnya menjadi varians yang tercatat.
- Serah terima: semua di atas modal disetorkan dan shift berikutnya membuka sesi baru.
Memodelkan sesi kas, bukan saldo
Entitas inti adalah CashSession yang tercakup pada satu cabang, satu karyawan, dan satu status. Uang disimpan sebagai bilangan bulat Rupiah Indonesia di seluruh basis kode, tidak pernah sebagai angka floating-point, karena IDR tidak memiliki sub-unit dalam penggunaan sehari-hari dan float diam-diam merusak jumlah. Jumlah pembukaan dan penutupan-terhitung berada langsung di sesi; semua yang terjadi di antaranya adalah aliran pergerakan kas yang mereferensikan sesi tersebut.
model CashSession {
id String @id @default(cuid())
branchId String // every query scopes by this
openedById String
closedById String?
status SessionStatus @default(OPEN)
openingFloat Int // integer IDR, e.g. 500000
countedClosing Int? // what the cashier physically counted
expectedClosing Int? // computed at close, snapshotted
variance Int? // countedClosing - expectedClosing
openedAt DateTime @default(now())
closedAt DateTime?
branch Branch @relation(fields: [branchId], references: [id])
movements CashMovement[]
@@index([branchId, status])
}
enum SessionStatus { OPEN CLOSED SUSPENDED }
model CashMovement {
id String @id @default(cuid())
sessionId String
branchId String
type MovementType // CASH_IN | CASH_OUT
source String // 'order' | 'expense' | 'kasbon'
refId String? // order/expense id for traceability
amount Int // always positive integer IDR
createdAt DateTime @default(now())
session CashSession @relation(fields: [sessionId], references: [id])
@@index([sessionId])
}
enum MovementType { CASH_IN CASH_OUT }Setiap query daftar dan detail di API tercakup berdasarkan branchId. Versi awal membiarkan seorang manajer di cabang A memuat id sesi dari cabang B hanya dengan menebak URL, sebuah IDOR klasik. Mencakup berdasarkan branchId di klausa where Prisma pada setiap pembacaan menutupnya. Perlakukan id sesi sebagai non-rahasia dan cakupan cabang sebagai batas yang sebenarnya.
Buku kas adalah tampilan turunan, bukan tabel yang ditulisi
Pemilik menginginkan buku kas berjalan, disebut buku kas, yang menampilkan setiap entri secara kronologis. Pendekatan naif adalah tabel CashLedgerEntry yang Anda sisipi di setiap transaksi. Saya sengaja tidak membangun itu. Sebagai gantinya CashLedgerEntry adalah tampilan union turunan yang dirakit saat baca dari tabel sumber sebenarnya: pergerakan kas, pembayaran yang diselesaikan, dan pengeluaran. Tidak ada jalur tulis ke buku besar, sehingga ia tidak akan pernah menyimpang dari transaksi yang diringkasnya.
Ini mencerminkan aturan yang saya terapkan di mana-mana dalam ERP: cache dan ringkasan harus memiliki satu sumber kebenaran. Jumlah stok produk juga adalah cache; kebenarannya adalah jumlah dari pergerakan stok. Jika angka turunan dan sumbernya tidak sepakat, sumber yang menang dan nilai turunan cukup dihitung ulang. Prinsip itu menghilangkan seluruh kategori bug rekonsiliasi di mana ringkasan diam-diam berbohong.
Ekspektasi vs hitungan: rumus varians
Saat tutup, layanan menjumlahkan pergerakan untuk sesi tersebut dan menghitung kas ekspektasi. Rumusnya adalah yang standar industri: modal awal ditambah total kas masuk dikurangi total kas keluar. Kasir memasukkan jumlah yang dihitung secara fisik, dan varians adalah hitungan dikurangi ekspektasi. Varians positif adalah kelebihan, yang negatif adalah kekurangan, sesuai dengan konsep akuntansi cash over and short.
// cash-session.service.ts (close flow, simplified)
async closeSession(sessionId: string, countedClosing: number, actor: Actor) {
const session = await this.prisma.cashSession.findFirstOrThrow({
where: { id: sessionId, branchId: actor.branchId, status: 'OPEN' },
include: { movements: true },
});
const cashIn = session.movements
.filter(m => m.type === 'CASH_IN')
.reduce((s, m) => s + m.amount, 0);
const cashOut = session.movements
.filter(m => m.type === 'CASH_OUT')
.reduce((s, m) => s + m.amount, 0);
const expectedClosing = session.openingFloat + cashIn - cashOut;
const variance = countedClosing - expectedClosing; // + over, - short
return this.prisma.cashSession.update({
where: { id: session.id },
data: {
status: 'CLOSED',
closedById: actor.userId,
countedClosing,
expectedClosing, // snapshot, so later edits can't rewrite history
variance,
closedAt: new Date(),
},
});
}Perhatikan bahwa penutupan ekspektasi disnapshot ke baris pada waktu tutup. Saya tidak menghitungnya ulang kemudian dari pergerakan langsung, karena koreksi bertanggal mundur pada suatu order tidak boleh diam-diam mengubah terhadap apa suatu shift direkonsiliasi berbulan-bulan lalu. Snapshot membekukan rekonsiliasi sebagaimana adanya ketika manusia menghitung laci.
Penyelesaian pembayaran menggunakan kunci idempotensi. Kasir menekan konfirmasi dua kali, atau koneksi seluler tidak stabil yang mengulang, tidak boleh membuat dua pergerakan kas masuk untuk satu cucian. Layanan penyelesaian mendeduplikasi berdasarkan kunci yang dipasok klien sehingga pengulangan mengembalikan hasil asli alih-alih menghitung ganda. Idempotensi adalah bagian inti dari arus kas yang sehat, bukan tambahan.
Penanganan varians dan siapa yang boleh menutup
Tidak setiap varians adalah kecurangan, dan sistem tidak boleh memperlakukannya begitu. Target carwash jauh di bawah satu persen dari penjualan tunai; panduan industri menempatkan laci yang dikelola baik di bawah 0,1 persen. Saya membangun kebijakan dua tingkat: varians kecil dalam ambang yang dapat dikonfigurasi ditutup otomatis dengan catatan tercatat, sementara apa pun di luar itu memerlukan manajer untuk meninjau dan menyetujui penutupan. Satu kekurangan kecil didokumentasikan tanpa tuduhan; modul menampilkan pola lintas shift, karena kekurangan kecil berulang adalah sinyal yang lebih kuat daripada satu yang besar.
| Rentang varians | Siapa yang boleh menutup | Apa yang dicatat |
|---|---|---|
| Dalam ambang | Kasir yang bertugas | Tutup otomatis dengan catatan varians |
| Di luar ambang | Hanya manajer atau pemilik | Teks alasan plus id penyetuju |
| Pola berulang | Tinjauan pemilik | Tanda laporan lintas shift |
Jejak audit yang membuatnya dapat dipertanggungjawabkan
Setiap pembukaan, pergerakan, dan penutupan menulis ke AuditLog append-only. Baris tidak pernah diperbarui atau dihapus, informasi yang dapat mengidentifikasi pribadi diredaksi saat tulis, dan setiap entri mencatat siapa melakukan apa, kapan, dan terhadap sesi serta cabang mana. Ketika pemilik bertanya mengapa cabang B kurang Rp 40.000 pada hari Selasa, jawabannya adalah query, bukan tebakan. Log audit itulah yang mengubah setumpuk uang menjadi proses yang dapat dipertanggungjawabkan yang benar-benar dipercaya pemilik.
Hasil dari desain ini adalah rekonsiliasi berhenti menjadi perdebatan. Angka-angka diturunkan dari pergerakan yang tidak dapat diubah, varians dibekukan saat tutup, izin menentukan siapa yang dapat menyetujui, dan log audit mengingat segalanya. Itulah perbedaan antara perangkat lunak yang melacak uang dan perangkat lunak yang pemilik bersedia mempertaruhkan bisnisnya.

