Tokiva-backend-Node / src /services /transaksi.service.ts
anoderb
fix: increase Prisma interactive transaction timeout to 30000ms
8f4ca64
Raw
History Blame Contribute Delete
17.9 kB
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;
}
}