Merancang Rekonsiliasi Sesi Kas POS di Sebuah ERP

Foto oleh Kecko via Openverse (CC BY 2.0)
Sesi kas adalah catatan per-shift dengan modal awal, aliran pergerakan kas, dan jumlah penutupan terhitung. Ini lebih baik daripada saldo berjalan tunggal karena menyimpan siapa yang bertugas, kapan setiap pergerakan terjadi, dan membekukan rekonsiliasi saat tutup. Saldo berjalan menyembunyikan riwayat itu dan mengubah bug konkurensi apa pun menjadi uang yang tidak bisa Anda lacak.
Penutupan ekspektasi sama dengan modal awal ditambah total kas masuk dikurangi total kas keluar untuk shift. Kasir menghitung laci fisik dan varians adalah jumlah terhitung dikurangi jumlah ekspektasi. Varians positif adalah kelebihan dan yang negatif adalah kekurangan, sesuai konsep akuntansi cash over and short.
Tabel buku besar yang ditulisi bisa diam-diam menyimpang dari transaksi yang seharusnya diringkasnya, terutama saat ada pengulangan atau kegagalan sebagian. Menurunkan buku kas sebagai union saat-baca dari tabel pergerakan, pembayaran, dan pengeluaran yang sebenarnya berarti tidak ada jalur tulis untuk dirusak. Jika total turunan pernah tidak sepakat dengan sumbernya, sumber yang menang dan tampilan dihitung ulang.
Gunakan kebijakan bertingkat. Varians dalam ambang yang dapat dikonfigurasi bisa ditutup oleh kasir yang bertugas dengan catatan tercatat. Apa pun di luar ambang memerlukan manajer atau pemilik untuk meninjau, memasukkan alasan, dan menyetujui, dan kekurangan kecil berulang sebaiknya ditandai untuk tinjauan pemilik lintas shift alih-alih dinilai satu shift saja.
Tulis setiap pembukaan, pergerakan, dan penutupan ke log audit append-only yang tidak pernah diperbarui atau dihapus, dengan informasi yang dapat mengidentifikasi pribadi diredaksi saat tulis. Snapshot jumlah penutupan ekspektasi ke sesi saat tutup sehingga suntingan bertanggal mundur kemudian tidak bisa menulis ulang terhadap apa suatu shift direkonsiliasi. Bersama-sama ini membuat selisih apa pun dapat dijawab dengan query alih-alih tebakan.

Foto oleh Kecko via Openverse (CC BY 2.0)
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.
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.
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.
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.
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.
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 |
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.