Tokiva-backend-Node / src /services /bon.service.ts
anoderb
feat: implement chatbot service, active shift logic corrections, audit logs, and void transaction tool
48013ee
Raw
History Blame Contribute Delete
6.27 kB
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,
});
}
}