rr-locker-api / src /services /emiService.js
MD.Riad Rayhan
Node.js + Socket.IO backend (migrated from FastAPI)
839aef2 verified
Raw History Blame Contribute Delete
5.13 kB
import { Op } from 'sequelize';
import crypto from 'crypto';
import {
EMIContract, EMIPayment, Device,
} from '../models/index.js';
import { DeviceService } from './deviceService.js';
import { utcNow } from '../utils/time.js';
/** Add whole months to a "YYYY-MM-DD" date string, clamping the day. */
function addMonths(dateStr, months, dayOverride = null) {
const d = new Date(`${dateStr}T00:00:00Z`);
const targetMonth = d.getUTCMonth() + months;
const year = d.getUTCFullYear() + Math.floor(targetMonth / 12);
const month = ((targetMonth % 12) + 12) % 12;
let day = dayOverride != null ? dayOverride : d.getUTCDate();
const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
day = Math.min(day, lastDay);
const mm = String(month + 1).padStart(2, '0');
const dd = String(day).padStart(2, '0');
return `${year}-${mm}-${dd}`;
}
function todayStr() {
return new Date().toISOString().slice(0, 10);
}
function round2(n) {
return Math.round((Number(n) + Number.EPSILON) * 100) / 100;
}
export const EMIService = {
generateContractNumber() {
const ts = new Date().toISOString().slice(0, 10).replace(/-/g, '');
return `EMI-${ts}-${crypto.randomUUID().replace(/-/g, '').slice(0, 8).toUpperCase()}`;
},
calculateEmi(principal, rate, tenure) {
const p = Number(principal);
const r = Number(rate);
if (r === 0) return p / tenure;
const totalInterest = p * (r / 100) * (tenure / 12);
const totalAmount = p + totalInterest;
return round2(totalAmount / tenure);
},
async createContract(data, userId = null) {
const principal = round2(Number(data.product_price) - Number(data.down_payment));
const emiAmount = this.calculateEmi(principal, data.interest_rate, data.tenure_months);
const totalAmount = round2(emiAmount * data.tenure_months);
const endDate = addMonths(data.start_date, data.tenure_months);
const contract = await EMIContract.create({
contract_number: this.generateContractNumber(),
customer_id: data.customer_id,
device_id: data.device_id,
product_name: data.product_name,
product_price: data.product_price,
down_payment: data.down_payment,
principal_amount: principal,
interest_rate: data.interest_rate,
tenure_months: data.tenure_months,
emi_amount: emiAmount,
total_amount: totalAmount,
start_date: data.start_date,
end_date: endDate,
emi_due_day: data.emi_due_day,
grace_period_days: data.grace_period_days,
notes: data.notes ?? null,
created_by: userId,
});
const dueDay = Math.min(data.emi_due_day, 28);
const payments = [];
for (let i = 0; i < data.tenure_months; i += 1) {
payments.push({
contract_id: contract.id,
installment_number: i + 1,
due_amount: emiAmount,
due_date: addMonths(data.start_date, i + 1, dueDay),
status: 'pending',
});
}
await EMIPayment.bulkCreate(payments);
const device = await Device.findByPk(data.device_id);
if (device) {
device.customer_id = data.customer_id;
await device.save();
}
return contract;
},
async recordPayment(paymentId, data, userId = null) {
const payment = await EMIPayment.findByPk(paymentId);
if (!payment) return null;
payment.paid_amount = data.paid_amount;
payment.late_fee = data.late_fee ?? 0;
payment.payment_method = data.payment_method ?? null;
payment.payment_reference = data.payment_reference ?? null;
payment.paid_date = utcNow();
payment.recorded_by = userId;
payment.status = Number(payment.paid_amount) >= Number(payment.due_amount) ? 'paid' : 'partial';
const contract = await EMIContract.findByPk(payment.contract_id);
contract.total_paid = round2(Number(contract.total_paid) + Number(payment.paid_amount));
if (payment.status === 'paid') contract.emis_paid += 1;
if (contract.emis_paid >= contract.tenure_months) {
contract.status = 'completed';
const device = await Device.findByPk(contract.device_id);
if (device && device.status === 'locked') {
await DeviceService.updateDeviceStatus(device.id, 'active', userId, 'Contract completed');
}
}
await contract.save();
await payment.save();
return payment;
},
getOverduePayments(asOfDate = null) {
const cutoff = asOfDate || todayStr();
return EMIPayment.findAll({
where: {
due_date: { [Op.lt]: cutoff },
status: { [Op.in]: ['PENDING', 'PARTIAL'] },
},
});
},
getPaymentsDueSoon(days = 3) {
const today = todayStr();
const dueDate = addMonths(today, 0); // base
const d = new Date(`${today}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + days);
const future = d.toISOString().slice(0, 10);
return EMIPayment.findAll({
where: {
due_date: { [Op.lte]: future, [Op.gte]: today },
status: 'PENDING',
},
});
},
};
export default EMIService;