import prisma from '../utils/db'; import { catatAudit } from '../utils/audit'; import { APIError } from '../utils/errors'; export class BonService { static async getBon(skip = 0, limit = 10, statusFilter?: 'aktif' | 'lunas' | 'macet', memberId?: number) { const where: any = {}; if (statusFilter) where.status = statusFilter; if (memberId) where.member_id = memberId; return await prisma.bon.findMany({ where, orderBy: { created_at: 'desc' }, skip, take: limit, include: { member: true, transaksi: true, }, }); } static async getBonById(id: number) { const bon = await prisma.bon.findUnique({ where: { id }, include: { member: true, transaksi: true, bon_cicilan: true, }, }); if (!bon) { throw new APIError('Pencatatan kasbon tidak ditemukan', 404); } return bon; } static async bayarCicilan(params: { bonId: number; nominal: number; metode: 'tunai' | 'transfer'; actorId: number; ip: string; userAgent: string; }) { const { bonId, nominal, metode, actorId, ip, userAgent } = params; const bon = await this.getBonById(bonId); if (bon.status === 'lunas') { throw new APIError('Kasbon ini sudah lunas didebet sebelumnya', 400); } const sisaBon = Number(bon.sisa_bon); if (nominal > sisaBon) { throw new APIError(`Nominal pembayaran Rp ${nominal.toLocaleString('id-ID')} melebihi sisa tagihan Rp ${sisaBon.toLocaleString('id-ID')}`, 400); } // Pastikan kasir memiliki shift aktif const shift = await prisma.shift.findFirst({ where: { user_id: actorId, status: 'buka', }, }); if (!shift) { throw new APIError('Shift kasir Anda belum dibuka. Harus membuka shift terlebih dahulu untuk menerima pembayaran.', 400); } // 1. Buat record cicilan await prisma.bon_cicilan.create({ data: { bon_id: bon.id, nominal: nominal, metode: metode, user_id: actorId, }, }); // 2. Kurangi sisa bon const sisaLama = sisaBon; const sisaBaru = sisaLama - nominal; const statusBaru = sisaBaru <= 0 ? 'lunas' : bon.status; await prisma.bon.update({ where: { id: bonId }, data: { sisa_bon: sisaBaru, status: statusBaru as any, }, }); // 3. Kurangi total bon member const totalBonMemberLama = Number(bon.member.total_bon); const totalBonMemberBaru = Math.max(0, totalBonMemberLama - nominal); await prisma.member.update({ where: { id: bon.member_id }, data: { total_bon: totalBonMemberBaru }, }); // 4. Masukkan ke kas shift const updateShift: any = {}; if (metode === 'tunai') { updateShift.total_tunai = Number(shift.total_tunai) + nominal; } else if (metode === 'transfer') { updateShift.total_transfer = Number(shift.total_transfer) + nominal; } await prisma.shift.update({ where: { id: shift.id }, data: updateShift, }); // 5. Audit log await catatAudit({ userId: actorId, aksi: 'update', entitas: 'bon', entitasId: bon.id, dataLama: { sisa_bon: sisaLama, status: bon.status }, dataBaru: { sisa_bon: sisaBaru, status: statusBaru, pembayaran_cicilan: nominal }, ipAddress: ip, userAgent, }); return await this.getBonById(bonId); } static async kirimReminderWA(bonId: number) { const bon = await prisma.bon.findUnique({ where: { id: bonId }, include: { member: true }, }); if (!bon || !bon.member) { throw new APIError('Gagal membuat pengingat WhatsApp. Data member tidak ditemukan.', 400); } const { nama, nomor_hp } = bon.member; if (!nomor_hp) { throw new APIError('Gagal membuat pengingat WhatsApp. Nomor HP member tidak terisi.', 400); } // Bersihkan nomor HP let cleanedNo = nomor_hp.trim(); if (cleanedNo.startsWith('0')) { cleanedNo = '62' + cleanedNo.slice(1); } else if (cleanedNo.startsWith('+')) { cleanedNo = cleanedNo.slice(1); } // Format pesan const jatuhTempoStr = bon.jatuh_tempo ? new Date(bon.jatuh_tempo).toLocaleDateString('id-ID', { day: '2-digit', month: '2-digit', year: 'numeric' }) : 'secepatnya'; const sisaRupiah = Math.floor(Number(bon.sisa_bon)).toLocaleString('id-ID').replace(/,/g, '.'); const pesan = `Halo *${nama}*,\n\n` + `Kami dari *Tokiva POS* ingin mengingatkan bahwa Anda memiliki tagihan kasbon ` + `sebesar *Rp ${sisaRupiah}* yang jatuh tempo pada tanggal *{jatuh_tempo_str}*.\n\n` + `Silakan melakukan pembayaran cicilan atau pelunasan melalui kasir Tokiva. ` + `Terima kasih atas kerjasamanya!`; const formatPesan = pesan.replace('{jatuh_tempo_str}', jatuhTempoStr); const encodedMessage = encodeURIComponent(formatPesan); const waLink = `https://wa.me/${cleanedNo}?text=${encodedMessage}`; // Tandai reminder terkirim await prisma.bon.update({ where: { id: bonId }, data: { reminder_sent: 1 }, }); // Catat notifikasi await prisma.notifikasi.create({ data: { tipe: 'bon_jatuh_tempo', judul: `WhatsApp Tagihan Dikirim: ${nama}`, pesan: `Link WhatsApp pengingat tagihan Rp ${sisaRupiah} untuk ${nama} telah dibuat.`, referensi_id: bon.id, }, }); return waLink; } static async bayarCicilanByMemberId(params: { memberId: number; nominal: number; metode: 'tunai' | 'transfer'; actorId: number; ip: string; userAgent: string; }) { const { memberId, nominal, metode, actorId, ip, userAgent } = params; // Cari bon teraktif milik member const bon = await prisma.bon.findFirst({ where: { member_id: memberId, status: 'aktif', }, orderBy: { created_at: 'asc', // bayar dari bon paling lama terutang }, }); if (!bon) { throw new APIError('Tidak ada pencatatan kasbon aktif untuk member ini.', 404); } return await this.bayarCicilan({ bonId: bon.id, nominal, metode, actorId, ip, userAgent, }); } }