import prisma from '../utils/db'; import { DiskonService } from './diskon.service'; import { StokService } from './stok.service'; import { catatAudit } from '../utils/audit'; import { generateNoTransaksi, generateNoRetur } from '../utils/kodeGenerator'; import { APIError } from '../utils/errors'; export class TransaksiService { static async prosesTransaksi(params: { data: any; actorId: number; ip: string; userAgent: string; }) { const { data, actorId, ip, userAgent } = params; // 1. Pastikan shift kasir aktif const shift = await prisma.shift.findFirst({ where: { user_id: actorId, status: 'buka', }, }); if (!shift) { throw new APIError('Shift kasir Anda belum dibuka. Silakan buka shift terlebih dahulu.', 400); } // 2. Hitung harga dan diskon menggunakan diskon_service const itemsInput = data.items.map((item: any) => ({ produk_id: item.produk_id, qty: item.qty, })); const calc = await DiskonService.hitungDiskonDanHarga(itemsInput, data.member_id); const totalAkhir = calc.total_akhir; const subtotal = calc.subtotal_dasar; const diskonTotal = calc.total_diskon_keseluruhan; // 3. Validasi Pembayaran const nominalBayar = data.pembayaran.reduce((acc: number, p: any) => acc + p.nominal, 0); if (nominalBayar < totalAkhir) { throw new APIError(`Nominal pembayaran kurang. Dibutuhkan: Rp ${totalAkhir.toLocaleString('id-ID')}, Dibayarkan: Rp ${nominalBayar.toLocaleString('id-ID')}`, 400); } // Cek Pembayaran bertipe BON const bonPembayaran = data.pembayaran.find((p: any) => p.metode === 'bon'); let member: any = null; if (bonPembayaran) { if (!data.member_id) { throw new APIError('Pembayaran tipe Kasbon (Bon) memerlukan pendaftaran Member/Pelanggan.', 400); } member = await prisma.member.findFirst({ where: { id: data.member_id, aktif: 1 }, }); if (!member) { throw new APIError('Member tidak valid atau tidak aktif.', 400); } // Validasi limit bon const sisaLimit = Number(member.limit_bon) - Number(member.total_bon); if (bonPembayaran.nominal > sisaLimit) { throw new APIError(`Nominal bon Rp ${bonPembayaran.nominal.toLocaleString('id-ID')} melebihi sisa limit kredit member. Sisa limit: Rp ${sisaLimit.toLocaleString('id-ID')}`, 400); } } const statusTrans = bonPembayaran ? 'bon' : 'lunas'; // 4. Generate Nomor Transaksi const noTransaksi = await generateNoTransaksi(); // Hitung kembalian const kembalian = nominalBayar - totalAkhir; // 5. Buat Transaksi (gunakan transaction block) const result = await prisma.$transaction(async (tx) => { const txRecord = await tx.transaksi.create({ data: { no_transaksi: noTransaksi, shift_id: shift.id, user_id: actorId, member_id: data.member_id ?? null, tanggal: new Date(), waktu: new Date(), subtotal: subtotal, diskon_total: diskonTotal, diskon_member: data.member_id ? calc.total_diskon_transaksi : 0, pajak: 0, total: totalAkhir, bayar: nominalBayar, kembalian: kembalian, status: statusTrans as any, is_synced: 1, local_id: data.local_id ?? null, catatan: data.catatan ?? null, }, }); // 6. Simpan detail transaksi + Potong Stok (FEFO) for (const calcItem of calc.cart_items) { const prod = calcItem.produk; const reqQty = calcItem.qty; const hargaOriginal = calcItem.harga_original; const diskonItem = calcItem.diskon_item; const itemDiskonId = calcItem.diskon_id; // Potong stok secara FEFO const deductedBatches = await StokService.kurangiStokFEFO({ produkId: prod.id, qtyToDeduct: reqQty, userId: actorId, referensi: noTransaksi, catatan: `Penjualan #${noTransaksi}`, }); // Simpan detail transaksi per batch untuk keakuratan tracking FEFO for (const dBatch of deductedBatches) { const proporsi = dBatch.qty / reqQty; await tx.transaksi_detail.create({ data: { transaksi_id: txRecord.id, produk_id: prod.id, nama_produk: prod.nama, qty: dBatch.qty, satuan: prod.satuan, harga_satuan: hargaOriginal, diskon_item: diskonItem * proporsi, subtotal: (hargaOriginal * dBatch.qty) - (diskonItem * proporsi), diskon_id: itemDiskonId ?? null, batch_id: dBatch.batchId, }, }); } } // 7. Simpan Pembayaran const shiftUpdate: any = {}; for (const p of data.pembayaran) { await tx.pembayaran.create({ data: { transaksi_id: txRecord.id, metode: p.metode, nominal: p.nominal, referensi: p.referensi ?? null, }, }); // Akumulasikan ke totals shift if (p.metode === 'tunai') { shiftUpdate.total_tunai = Number(shift.total_tunai) + p.nominal; } else if (p.metode === 'qris') { shiftUpdate.total_qris = Number(shift.total_qris) + p.nominal; } else if (p.metode === 'transfer') { shiftUpdate.total_transfer = Number(shift.total_transfer) + p.nominal; } else if (p.metode === 'bon') { shiftUpdate.total_bon = Number(shift.total_bon) + p.nominal; } } // Jika kembalian dibayarkan dari tunai, kurangi total_tunai shift if (kembalian > 0) { shiftUpdate.total_tunai = (shiftUpdate.total_tunai ?? Number(shift.total_tunai)) - kembalian; } await tx.shift.update({ where: { id: shift.id }, data: { ...shiftUpdate, total_transaksi: Number(shift.total_transaksi) + 1, total_diskon: Number(shift.total_diskon) + diskonTotal, }, }); // 8. Jika status BON, buat pencatatan piutang (bon) if (statusTrans === 'bon' && bonPembayaran) { const jatuhTempo = new Date(); jatuhTempo.setDate(jatuhTempo.getDate() + 30); // Default 30 hari await tx.bon.create({ data: { transaksi_id: txRecord.id, member_id: data.member_id, total_bon: bonPembayaran.nominal, sisa_bon: bonPembayaran.nominal, jatuh_tempo: jatuhTempo, status: 'aktif', }, }); // Tambahkan nominal bon ke total_bon milik member await tx.member.update({ where: { id: data.member_id }, data: { total_bon: Number(member.total_bon) + bonPembayaran.nominal, }, }); } // 9. Poin Member (Loyalty Program) if (data.member_id) { const dbMember = await tx.member.findUnique({ where: { id: data.member_id } }); if (dbMember) { const poinSetting = await tx.pengaturan.findUnique({ where: { kunci: 'poin_member' }, }); let perRp = 10000; let poinPerStep = 1; if (poinSetting) { try { const val = JSON.parse(poinSetting.nilai); perRp = val.per_rp ?? 10000; poinPerStep = val.poin ?? 1; } catch (e) {} } const poinBaru = Math.floor(totalAkhir / perRp) * poinPerStep; if (poinBaru > 0) { await tx.member.update({ where: { id: data.member_id }, data: { total_poin: Number(dbMember.total_poin) + poinBaru, }, }); } } } return txRecord; }, { timeout: 30000 }); // 10. Audit Log await catatAudit({ userId: actorId, aksi: 'create', entitas: 'transaksi', entitasId: result.id, dataBaru: { no_transaksi: noTransaksi, total: totalAkhir, status: statusTrans }, ipAddress: ip, userAgent, }); return await prisma.transaksi.findUnique({ where: { id: result.id }, include: { transaksi_detail: true, pembayaran: true, }, }); } static async prosesVoidTransaksi( transaksiId: number, alasan: string, actorId: number, ip: string, userAgent: string ) { const transaksi = await prisma.transaksi.findUnique({ where: { id: transaksiId }, include: { transaksi_detail: true, pembayaran: true, }, }); if (!transaksi) { throw new APIError('Transaksi tidak ditemukan', 404); } if (transaksi.status === 'void') { throw new APIError('Transaksi ini sudah berstatus void sebelumnya', 400); } await prisma.$transaction(async (tx) => { // 1. Kembalikan stok yang dipotong for (const detail of transaksi.transaksi_detail) { const produk = await tx.produk.findUnique({ where: { id: detail.produk_id } }); if (produk) { const stokSebelum = Number(produk.stok); const stokSesudah = stokSebelum + Number(detail.qty); await tx.produk.update({ where: { id: detail.produk_id }, data: { stok: stokSesudah }, }); // Kembalikan ke batch semula jika ada batch_id if (detail.batch_id) { const batch = await tx.stok_batch.findUnique({ where: { id: detail.batch_id } }); if (batch) { await tx.stok_batch.update({ where: { id: detail.batch_id }, data: { qty_sisa: Number(batch.qty_sisa) + Number(detail.qty) }, }); } } // Catat log mutasi retur/penyesuaian stok await tx.stok_log.create({ data: { produk_id: detail.produk_id, batch_id: detail.batch_id, tipe: 'retur', qty: detail.qty, stok_sebelum: stokSebelum, stok_sesudah: stokSesudah, referensi: `VOID-${transaksi.no_transaksi}`, user_id: actorId, catatan: `Void Transaksi #${transaksi.no_transaksi}. Alasan: ${alasan}`, }, }); } } // 2. Jika ada BON, batalkan bon-nya if (transaksi.status === 'bon') { const bon = await tx.bon.findFirst({ where: { transaksi_id: transaksiId } }); if (bon) { await tx.bon.update({ where: { id: bon.id }, data: { status: 'lunas' }, }); // Potong total_bon di profile member const member = await tx.member.findUnique({ where: { id: transaksi.member_id! } }); if (member) { await tx.member.update({ where: { id: transaksi.member_id! }, data: { total_bon: Math.max(0, Number(member.total_bon) - Number(bon.sisa_bon)), }, }); } } } // 3. Potong Poin Member yang didapat dari transaksi ini if (transaksi.member_id) { const member = await tx.member.findUnique({ where: { id: transaksi.member_id } }); if (member) { const poinSetting = await tx.pengaturan.findUnique({ where: { kunci: 'poin_member' }, }); let perRp = 10000; let poinPerStep = 1; if (poinSetting) { try { const val = JSON.parse(poinSetting.nilai); perRp = val.per_rp ?? 10000; poinPerStep = val.poin ?? 1; } catch (e) {} } const poinVoid = Math.floor(Number(transaksi.total) / perRp) * poinPerStep; await tx.member.update({ where: { id: transaksi.member_id }, data: { total_poin: Math.max(0, Number(member.total_poin) - poinVoid), }, }); } } // 4. Sesuaikan totals shift kasir const shift = await tx.shift.findUnique({ where: { id: transaksi.shift_id } }); if (shift) { const shiftUpdate: any = {}; for (const p of transaksi.pembayaran) { if (p.metode === 'tunai') { shiftUpdate.total_tunai = Number(shift.total_tunai) - Number(p.nominal); } else if (p.metode === 'qris') { shiftUpdate.total_qris = Number(shift.total_qris) - Number(p.nominal); } else if (p.metode === 'transfer') { shiftUpdate.total_transfer = Number(shift.total_transfer) - Number(p.nominal); } else if (p.metode === 'bon') { shiftUpdate.total_bon = Number(shift.total_bon) - Number(p.nominal); } } // Kembalikan penyesuaian jika ada kembalian tunai if (Number(transaksi.kembalian) > 0) { shiftUpdate.total_tunai = (shiftUpdate.total_tunai ?? Number(shift.total_tunai)) + Number(transaksi.kembalian); } await tx.shift.update({ where: { id: transaksi.shift_id }, data: { ...shiftUpdate, total_transaksi: Math.max(0, Number(shift.total_transaksi) - 1), total_diskon: Math.max(0, Number(shift.total_diskon) - Number(transaksi.diskon_total)), }, }); } // 5. Set status transaksi menjadi void await tx.transaksi.update({ where: { id: transaksiId }, data: { status: 'void', deleted_at: new Date(), }, }); // 6. Catat Notifikasi await tx.notifikasi.create({ data: { tipe: 'transaksi_void', judul: 'Transaksi Dibatalkan (Void)', pesan: `Transaksi #${transaksi.no_transaksi} dibatalkan oleh user ID ${actorId}. Alasan: ${alasan}`, referensi_id: transaksi.id, }, }); }); // 7. Audit Log await catatAudit({ userId: actorId, aksi: 'void', entitas: 'transaksi', entitasId: transaksi.id, dataLama: { status: 'lunas/bon' }, dataBaru: { status: 'void', alasan }, ipAddress: ip, userAgent, }); return await prisma.transaksi.findUnique({ where: { id: transaksiId }, include: { transaksi_detail: true, pembayaran: true, }, }); } static async prosesReturBarang( data: any, actorId: number, ip: string, userAgent: string ) { // Validate transaction const trans = await prisma.transaksi.findUnique({ where: { id: data.transaksi_id }, }); if (!trans) { throw new APIError('Transaksi asal tidak ditemukan', 404); } // Validate product is in transaction const detail = await prisma.transaksi_detail.findFirst({ where: { transaksi_id: data.transaksi_id, produk_id: data.produk_id, }, }); if (!detail) { throw new APIError('Produk tersebut tidak ada dalam riwayat transaksi ini', 400); } // Validate quantity retur const sumResult = await prisma.transaksi_retur.aggregate({ where: { transaksi_id: data.transaksi_id, produk_id: data.produk_id, }, _sum: { qty: true, }, }); const alreadyReturned = Number(sumResult._sum.qty ?? 0); const maxReturnable = Number(detail.qty) - alreadyReturned; if (data.qty > maxReturnable) { throw new APIError(`Jumlah retur melebihi jumlah pembelian. Maksimal yang bisa diretur: ${maxReturnable} ${detail.satuan}`, 400); } // Generate retur number const noRetur = await generateNoRetur(); const result = await prisma.$transaction(async (tx) => { // Create retur record const retur = await tx.transaksi_retur.create({ data: { transaksi_id: data.transaksi_id, no_retur: noRetur, user_id: actorId, produk_id: data.produk_id, qty: data.qty, alasan: data.alasan, tipe: data.tipe, nominal_kembali: data.nominal_kembali ?? 0, stok_kembali: data.stok_kembali ?? 1, }, }); // Restitusi stok jika stok_kembali = 1 if (data.stok_kembali === 1) { const produk = await tx.produk.findUnique({ where: { id: data.produk_id } }); if (produk) { const stokSebelum = Number(produk.stok); const stokSesudah = stokSebelum + data.qty; await tx.produk.update({ where: { id: data.produk_id }, data: { stok: stokSesudah }, }); // Kembalikan ke batch semula if (detail.batch_id) { const batch = await tx.stok_batch.findUnique({ where: { id: detail.batch_id } }); if (batch) { await tx.stok_batch.update({ where: { id: detail.batch_id }, data: { qty_sisa: Number(batch.qty_sisa) + data.qty }, }); } } // Catat log await tx.stok_log.create({ data: { produk_id: data.produk_id, batch_id: detail.batch_id, tipe: 'retur', qty: data.qty, stok_sebelum: stokSebelum, stok_sesudah: stokSesudah, referensi: noRetur, user_id: actorId, catatan: `Retur Barang dari Transaksi #${trans.no_transaksi}. Alasan: ${data.alasan}`, }, }); } } return retur; }); await catatAudit({ userId: actorId, aksi: 'create', entitas: 'transaksi_retur', entitasId: result.id, dataBaru: { no_retur: noRetur, qty: Number(data.qty), tipe: data.tipe }, ipAddress: ip, userAgent, }); return result; } }