Spaces:
Sleeping
Sleeping
anoderb
feat: implement chatbot service, active shift logic corrections, audit logs, and void transaction tool
48013ee | 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, | |
| }); | |
| } | |
| } | |