6
Alur Transaksi, Pembayaran, Retur, dan Piutang
Halaman ini menelusuri satu nota dari saat dibuat sampai lunas, diretur, atau dibatalkan. Fokusnya ada tiga: bagaimana nota dijamin tidak lahir dua kali, bagaimana uang bergerak antara pelanggan dan toko, dan bagaimana sisa_tagihan menjadi satu-satunya angka piutang yang dipakai seluruh sistem.
Nota lahir dari kunci_idempoten
TransaksiViewSet.create memvalidasi isi request dengan TransaksiBuatSerializer, lalu menyerahkan seluruh pekerjaan ke layanan.buat_transaksi backend/pembelian/views.py:120-1251. Kolom kunci_idempoten pada tabel transaksi bersifat unik backend/pembelian/models.py:41-422, dan sifat unik itulah yang menahan nota ganda.
buat_transaksi mencari nota dengan kunci yang sama sebelum menyentuh apa pun. Kalau ada, nota itu langsung dikembalikan tanpa efek samping. Kalau tidak ada, pembuatan nota berjalan di dalam satu transaction.atomic(), dan IntegrityError dari unique constraint ditangkap untuk mencari nota yang lebih dulu commit. Dengan begitu klik ganda dan dua request bersamaan berakhir pada satu nota backend/pembelian/layanan.py:31-463.
Frontend memegang satu UUID di dalam useRef selama form terbuka, justru supaya kiriman ulang memakai kunci yang sama frontend/src/app/(app)/pembelian/baru/page.tsx:98-994. Kunci baru dibuat hanya setelah respons sukses diterima frontend/src/app/(app)/pembelian/baru/page.tsx:128-1525. Uji konkurensi menjalankan enam request dengan kunci identik dan memastikan hanya satu nota terbentuk serta stok berkurang satu botol backend/tests/test_konkurensi.py:71-926.
sequenceDiagram
autonumber
participant UI as "form pembelian/baru"
participant API as TransaksiViewSet
participant L as buat_transaksi
participant DB as PostgreSQL
UI->>API: POST /api/transaksi/ plus kunci_idempoten
API->>L: buat_transaksi(pengguna, kunci_idempoten, ...)
L->>DB: SELECT transaksi WHERE kunci_idempoten
alt kunci sudah pernah dipakai
DB-->>L: baris lama
L-->>API: nota yang sudah ada
API-->>UI: 201 TransaksiDetailSerializer
else kunci baru
L->>DB: BEGIN atomic
L->>DB: nomor_berikutnya("TRX-YYYYMMDD")
L->>DB: INSERT transaksi, item, alokasi, pembayaran
L->>DB: ubah_stok per batch FEFO
L->>DB: COMMIT
API-->>UI: 201 TransaksiDetailSerializer
end
%% fig: Siklus satu request pembuatan nota
%% desc: Jalur atas mengembalikan nota yang sudah ada, jalur bawah membuat nota baru di dalam satu transaksi database.Harga dan total ditentukan server
_ambil_satuan memuat SatuanProduk lewat dengan_harga(hari_ini) dan menolak satuan atau produk yang tidak aktif dengan kode produk_nonaktif, serta menolak satuan tanpa harga dengan kode harga_belum_ada backend/pembelian/layanan.py:49-657. Karena harga_satuan dan subtotal item diambil dari objek satuan itu, request hanya menentukan satuan dan jumlah; angka rupiah tidak pernah datang dari klien.
Total dihitung sebagai harga * jumlah untuk setiap baris. Kalau tempo aktif dan uang_muka >= total, permintaan ditolak dengan kode uang_muka_lebih backend/pembelian/layanan.py:113-1178. Bentuk datanya disaring lebih dulu oleh serializer: tempo wajib menyertakan jatuh_tempo yang tidak boleh mundur, dan uang_muka hanya boleh diisi untuk transaksi tempo backend/pembelian/serializers.py:177-2019.
terbayar awal diisi uang_muka untuk transaksi tempo, atau total untuk transaksi tunai, sehingga transaksi tunai lahir dalam keadaan lunas backend/pembelian/layanan.py:119-13210. Nomor nota berawalan TRX-YYYYMMDD diambil dari nomor_berikutnya, yang menaikkan baris NomorUrut di bawah select_for_update agar dua kasir tidak mendapat nomor sama backend/inti/models.py:12-1811.
Alokasi batch dan pengurangan stok
Kebutuhan dihitung dalam botol, yaitu jumlah * isi_botol per baris. _kunci_batch_fefo mengunci batch yang layak jual dengan select_for_update() dan menyusunnya per produk urut (kedaluwarsa, id) backend/pembelian/layanan.py:68-7812. Kalau sisa seluruh batch sebuah produk lebih kecil dari kebutuhan, permintaan ditolak dengan kode stok_tidak_cukup beserta rincian diminta dan tersedia per produk backend/pembelian/layanan.py:101-11113.
Setiap item kemudian memecah kebutuhannya menjadi baris ItemTransaksiBatch dan memanggil ubah_stok(batch, -ambil, JenisMutasi.PENJUALAN, ...) backend/pembelian/layanan.py:134-15614. ubah_stok adalah satu-satunya jalan mengubah kolom sisa, dan ia menolak saldo negatif dengan kode stok_minus backend/stok/layanan.py:21-4415. Mekanisme batch, FEFO, dan opname dibahas di Alur Stok, Batch FEFO, dan Opname.
Pembayaran dan pelunasan
catat_pelunasan mengunci baris transaksi, menolak transaksi yang statusnya bukan selesai dengan kode status_transaksi, menolak jumlah yang melebihi sisa dengan kode bayar_lebih, lalu mencatat Pembayaran baru dan menambah terbayar backend/pembelian/layanan.py:181-19416. Endpoint POST /api/transaksi/{id}/bayar/ hanya meneruskan data tervalidasi ke fungsi itu backend/pembelian/views.py:127-13517, dengan jumlah minimal 0.01 dan metode terbatas pada tunai, transfer, atau qris backend/pembelian/serializers.py:204-20718.
| Aksi | Method dan path | Izin |
|---|---|---|
create | POST /api/transaksi/ | KELOLA_TRANSAKSI |
bayar | POST /api/transaksi/{id}/bayar/ | KELOLA_TRANSAKSI |
ajukan_batal | POST /api/transaksi/{id}/ajukan-batal/ | KELOLA_TRANSAKSI |
setujui_batal | POST /api/transaksi/{id}/setujui-batal/ | SETUJUI_PEMBATALAN |
tolak_batal | POST /api/transaksi/{id}/tolak-batal/ | SETUJUI_PEMBATALAN |
Pemetaan izin per action itu ditulis sebagai dictionary izin pada TransaksiViewSet backend/pembelian/views.py:67-7719. Peran mana yang memegang izin tersebut dijelaskan di Autentikasi, 2FA, dan Izin Peran, dan daftar lengkap endpoint beserta schema-nya ada di Endpoint API dan Skema OpenAPI.
Retur: potong piutang lebih dulu
buat_retur mengunci transaksi, mengunci seluruh ItemTransaksiBatch milik nota, menolak alokasi yang bukan bagian dari nota dengan kode alokasi_salah, mengunci batch terkait, lalu mengambil nomor RTR-YYYYMMDD dengan lebar tiga digit backend/pembelian/layanan.py:207-29220. Batas retur dijaga dua lapis: constraint retur_tidak_melebihi_alokasi pada tabel item_transaksi_batch backend/pembelian/models.py:120-13521, dan pemeriksaan jumlah_diretur + jumlah > jumlah_botol yang melempar retur_lebih backend/pembelian/layanan.py:245-25322.
Nilai retur tidak dihitung dari harga kali jumlah botol, melainkan dari selisih kumulatif. _nilai_retur membulatkan subtotal * botol / total_botol ke sen terdekat dengan bulatkan, lalu mengembalikan selisih antara posisi sesudah dan sebelum. Cara ini membuat retur penuh selalu sama persis dengan subtotal item, tanpa sisa pembulatan backend/pembelian/layanan.py:197-20423 dan backend/inti/format.py:6-724.
Batch yang kedaluwarsa tidak boleh diretur sebagai barang layak jual; kondisinya harus dicatat rusak dan permintaan layak ditolak dengan kode retur_kedaluwarsa backend/pembelian/layanan.py:245-25322. Stok masuk kembali lewat JenisMutasi.RETUR, dan untuk kondisi rusak langsung diikuti mutasi JenisMutasi.RUSAK pada batch yang sama backend/pembelian/layanan.py:259-26125.
Pembagian uangnya: potong = min(total, sisa) memotong tagihan lebih dulu, kembali = total - potong menjadi uang yang diserahkan. Keduanya disimpan pada kolom potong_piutang dan pengembalian_uang tabel retur backend/pembelian/models.py:164-17226, uang kembali dicatat sebagai Pembayaran dengan jenis=kembali, lalu total_retur ditambah dan terbayar dikurangi backend/pembelian/layanan.py:265-29127. Uji dengan uang muka 10000 dan retur senilai 10500 menghasilkan potong_piutang 4000.00 dan pengembalian_uang 6500.00 backend/tests/test_pembelian.py:248-27528.
graph TD
nilai["nilai retur kumulatif"] --> total["total retur"]
total --> potong["potong_piutang"]
total --> kembali["pengembalian_uang"]
potong --> tagihan["sisa_tagihan turun"]
kembali --> bayar["Pembayaran jenis kembali"]
total --> kolom["total_retur pada Transaksi"]
style potong fill:#8b5cf6,color:#fff
style kembali fill:#2d7d46,color:#fff
style kolom fill:#336791,color:#fff
%% fig: Pembelahan nilai retur
%% desc: Nilai retur dipotong ke tagihan lebih dulu, sisanya dibayarkan kembali dan dicatat sebagai Pembayaran berjenis kembali.Pembatalan butuh persetujuan
Status transaksi hanya punya tiga nilai: selesai, menunggu_batal, dan dibatalkan backend/pembelian/models.py:34-3729. ajukan_pembatalan hanya mengubah nota berstatus selesai menjadi menunggu_batal dan menyimpan alasannya backend/pembelian/layanan.py:295-30430. setujui_pembatalan mengembalikan botol yang belum diretur ke batch asal lewat JenisMutasi.BATAL, mengembalikan seluruh terbayar sebagai Pembayaran berjenis kembali, lalu menutup nota sebagai dibatalkan dengan terbayar nol backend/pembelian/layanan.py:312-34631. tolak_pembatalan mengembalikan status ke selesai dan menyimpan alasan_tolak_batal backend/pembelian/layanan.py:349-35732.
stateDiagram-v2
[*] --> selesai
selesai --> menunggu_batal: ajukan_pembatalan
menunggu_batal --> selesai: tolak_pembatalan
menunggu_batal --> dibatalkan: setujui_pembatalan
dibatalkan --> [*]
style selesai fill:#2d7d46,color:#fff
style menunggu_batal fill:#f59e0b,color:#000
style dibatalkan fill:#dc2626,color:#fff
%% fig: Siklus status transaksi
%% desc: Pembatalan selalu melewati status menunggu_batal, sehingga nota tidak bisa dibatalkan sepihak oleh kasir yang sama.sisa_tagihan sebagai satu-satunya angka piutang
sisa_tagihan bukan field biasa, melainkan GeneratedField dengan ekspresi total - total_retur - terbayar dan db_persist=True backend/pembelian/models.py:53-5733. Dua check constraint menjaga angkanya: tagihan_tidak_minus memastikan total >= total_retur + terbayar, dan terbayar_tidak_minus memastikan terbayar >= 0 backend/pembelian/models.py:83-8934. Untuk pemakaian di memori setelah objek diubah, properti sisa mengulang perhitungan yang sama tanpa perlu refresh backend/pembelian/models.py:94-9735.
Piutang per pelanggan dihitung di PelangganViewSet.get_queryset: jumlah sisa_tagihan dari transaksi yang sisanya positif dan tidak dibatalkan, dibungkus Subquery dan Coalesce supaya pelanggan tanpa piutang bernilai 0 backend/pembelian/views.py:43-5836. Filter status_bayar pada TransaksiViewSet memakai kolom yang sama: lunas untuk sisa_tagihan__lte=0, belum_lunas untuk sisa_tagihan__gt=0, dan jatuh_tempo untuk sisa positif yang jatuh_tempo sudah lewat backend/pembelian/views.py:105-11237. Label yang tampil ke pengguna berasal dari status_bayar(t) yang mendahulukan status dibatalkan backend/pembelian/serializers.py:20-2738.
graph LR
total["total"] --> sisa["sisa_tagihan"]
retur["total_retur"] --> sisa
terbayar["terbayar"] --> sisa
sisa --> piutang["total_piutang pelanggan"]
sisa --> status["status_bayar"]
style sisa fill:#8b5cf6,color:#fff
style piutang fill:#336791,color:#fff
%% fig: Turunan angka piutang
%% desc: Pelunasan dan retur mengubah kolom masukan, sementara sisa_tagihan dan total_piutang selalu dibaca dari hasil turunan yang sama.Konsekuensinya, pembayaran, retur, dan pembatalan tidak boleh menulis sisa_tagihan secara langsung. Ketiganya hanya mengubah terbayar atau total_retur, persis seperti yang dilakukan catat_pelunasan, buat_retur, dan setujui_pembatalan.
Kode galat yang harus ditangani
Semua penolakan aturan bisnis keluar sebagai AturanBisnisError dengan HTTP 409 dan dibungkus penangan_galat menjadi bentuk {"kode", "pesan", "rincian"} backend/inti/galat.py:8-1839.
| Kode | Pemicu | Sumber |
|---|---|---|
produk_nonaktif | satuan atau produk sudah tidak aktif | backend/pembelian/layanan.py:49-657 |
harga_belum_ada | harga satuan bernilai None pada tanggal hari ini | backend/pembelian/layanan.py:49-657 |
stok_tidak_cukup | total sisa batch kurang dari kebutuhan botol | backend/pembelian/layanan.py:101-11113 |
uang_muka_lebih | tempo dengan uang_muka >= total | backend/pembelian/layanan.py:113-1178 |
status_transaksi | nota tidak berstatus selesai atau tidak sedang menunggu_batal | backend/pembelian/layanan.py:173-17840 |
bayar_lebih | jumlah pelunasan melebihi t.sisa | backend/pembelian/layanan.py:181-19416 |
alokasi_salah | alokasi bukan milik nota yang diretur | backend/pembelian/layanan.py:207-29220 |
retur_lebih | jumlah diretur melebihi jumlah_botol alokasi | backend/pembelian/layanan.py:245-25322 |
retur_kedaluwarsa | retur layak pada batch yang sudah kedaluwarsa | backend/pembelian/layanan.py:245-25322 |
stok_minus | ubah_stok menghasilkan sisa negatif | backend/stok/layanan.py:21-4415 |
Nota tercetak
Setelah nota tersimpan, nota_pdf merender template pembelian/nota.html dengan nilai rupiah untuk total, total_retur, dan terbayar; baris sisa hanya diisi kalau sisa_tagihan masih positif backend/pembelian/nota.py:10-3641. Alur ekspor berkas dan laporan yang memakai angka yang sama dibahas di Dasbor, Laporan, dan Ekspor Berkas.