From 331c39a7eba6bad9f8e3779c781f506364afbc25 Mon Sep 17 00:00:00 2001 From: Yogeshwaran S Date: Fri, 12 Jun 2026 10:33:07 +0530 Subject: [PATCH] fix: solve the mathematical issue while calculating delayed days in fines module --- .../issues/components/TransactionModal.tsx | 6 +- server/package.json | 2 +- server/src/modules/fines/fine.service.ts | 125 ++++++++++++++++-- server/src/modules/issues/issue.service.ts | 12 +- 4 files changed, 127 insertions(+), 18 deletions(-) diff --git a/client/src/features/issues/components/TransactionModal.tsx b/client/src/features/issues/components/TransactionModal.tsx index df43723..e0a60ef 100644 --- a/client/src/features/issues/components/TransactionModal.tsx +++ b/client/src/features/issues/components/TransactionModal.tsx @@ -207,7 +207,7 @@ export const TransactionModal = ({ isOpen, onClose, onSubmit, editingRecord }: T

Circulation Terminal

-

{editingRecord ? "Adjust Loan Parameters" : "Issue New Book Voucher"}

+

{editingRecord ? "Edit issue details" : "Issue New Book"}

diff --git a/server/package.json b/server/package.json index 2ab3030..b78fde0 100644 --- a/server/package.json +++ b/server/package.json @@ -10,7 +10,7 @@ "test:integration": "cross-env NODE_ENV=test node --experimental-vm-modules node_modules/jest/bin/jest.js --config=jest.integration.config.ts", "test:coverage": "cross-env NODE_ENV=test node --experimental-vm-modules node_modules/jest/bin/jest.js --config=jest.integration.config.ts --coverage", "test:unit": "cross-env NODE_ENV=test node --experimental-vm-modules node_modules/jest/bin/jest.js --config=jest.unit.config.ts" - }, + }, "keywords": [], "author": "", "license": "ISC", diff --git a/server/src/modules/fines/fine.service.ts b/server/src/modules/fines/fine.service.ts index 8939bcf..cacd008 100644 --- a/server/src/modules/fines/fine.service.ts +++ b/server/src/modules/fines/fine.service.ts @@ -167,26 +167,21 @@ class FineService { } /** - * ⏰ BACKGROUND DAEMON AUTOMATED ENGINE - * Runs nightly to dynamically calculate accrual amounts across your split-rate structure: - * Days inside Active Plan window -> ₹10/day | Days outside/after Plan Expiry -> ₹20/day - */ -/** * ⏰ DUAL-TRIGGER FINE ACCRUAL SYNC ENGINE - * Can be triggered globally by midnight cron, or targeted via member_id for instant manual overrides! + * Fixed to calculate pure calendar days, removing millisecond/hour offsets. */ async runFineAccrualSync(targetMemberId?: string) { console.log(`🚀 Starting Fine Accrual Sync (${targetMemberId ? `Targeted: ${targetMemberId}` : 'Global Midnight Run'})...`); const today = new Date(); await sequelize.transaction(async (t) => { - // 🟢 Build dynamic conditions based on target + // Look for any active issues where the book isn't returned yet, + // and the due_date is less than or equal to today. const issueConditions: any = { returned_date: null, due_date: { [Symbol.for("lte") as any]: today } }; - // If triggered manually by a librarian update, lock it down to just this member if (targetMemberId) { issueConditions.member_id = targetMemberId; } @@ -211,16 +206,28 @@ class FineService { const expiryDate = planExpiryDateStr ? new Date(planExpiryDateStr) : null; const isPlanActiveNow = planStatusStr === "ACTIVE" && (expiryDate ? expiryDate >= today : true); + // 🧠 THE FIX: Strip out time values (hours, minutes, seconds) completely + // Set both dates strictly to midnight (00:00:00) so we calculate pure calendar days. + const todayMidnight = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + const dueDateObj = new Date(issue.due_date); + const dueDateMidnight = new Date(dueDateObj.getFullYear(), dueDateObj.getMonth(), dueDateObj.getDate()); + const msPerDay = 24 * 60 * 60 * 1000; - const totalDelayedDays = Math.ceil((today.getTime() - dueDateObj.getTime()) / msPerDay); + // Use Math.floor on midnight values to get an exact mathematical calendar count + const totalDelayedDays = Math.max(0, Math.floor((todayMidnight.getTime() - dueDateMidnight.getTime()) / msPerDay)); + + // If they changed the due date to today or a future date, delayed days is 0. Skip it. + if (totalDelayedDays === 0) continue; + let withinPlanDays = totalDelayedDays; let outsidePlanDays = 0; if (!isPlanActiveNow && expiryDate) { - if (expiryDate > dueDateObj) { - withinPlanDays = Math.max(0, Math.floor((expiryDate.getTime() - dueDateObj.getTime()) / msPerDay)); + const expiryMidnight = new Date(expiryDate.getFullYear(), expiryDate.getMonth(), expiryDate.getDate()); + if (expiryMidnight > dueDateMidnight) { + withinPlanDays = Math.max(0, Math.floor((expiryMidnight.getTime() - dueDateMidnight.getTime()) / msPerDay)); outsidePlanDays = Math.max(0, totalDelayedDays - withinPlanDays); } else { withinPlanDays = 0; @@ -244,6 +251,7 @@ class FineService { paid_status: false }, { transaction: t }); } else if (!existingFine.paid_status) { + // If the fine record exists but isn't paid, recalculate it dynamically await existingFine.update({ delayed_days: totalDelayedDays, fine_amount: totalComputedFineAmount @@ -252,6 +260,101 @@ class FineService { } }); } + + /** + * 🔄 ONE-TIME MASTER LEDGER RECALCULATION TOOL + * Loops through all existing unpaid fines to align old records perfectly + * with the corrected calendar-midnight layout mechanics. + */ + async forceRecalculateAllExistingFines() { + console.log("⚠️ Starting full database fine metrics recalculation sync..."); + const today = new Date(); + const todayMidnight = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + const msPerDay = 24 * 60 * 60 * 1000; + + let updatedCount = 0; + let purgedCount = 0; + + await sequelize.transaction(async (t) => { + // 1. Fetch all unpaid fines along with their core issue and member context data + const unpaidFines = await Fine.findAll({ + where: { paid_status: false }, + include: [ + { + model: Issue, + as: "issue", // Make sure this matches your Fine -> Issue association alias + include: [ + { + model: Member, + as: "member", + include: [{ model: MembershipPlan, as: "membership_plan" }] + } + ] + } + ], + transaction: t + }); + + for (const fine of unpaidFines) { + const issue = (fine as any).issue; + + // Safety Fallback: If the underlying issue record was missing, clean up the dead fine row + if (!issue) { + await fine.destroy({ transaction: t }); + purgedCount++; + continue; + } + + const member = issue.member; + const planExpiryDateStr = member?.expiry_date; + const planStatusStr = member?.membership_status || "ACTIVE"; + const expiryDate = planExpiryDateStr ? new Date(planExpiryDateStr) : null; + const isPlanActiveNow = planStatusStr === "ACTIVE" && (expiryDate ? expiryDate >= today : true); + + // Calculate pure calendar days difference using standard midnights + const dueDateObj = new Date(issue.due_date); + const dueDateMidnight = new Date(dueDateObj.getFullYear(), dueDateObj.getMonth(), dueDateObj.getDate()); + + const totalDelayedDays = Math.max(0, Math.floor((todayMidnight.getTime() - dueDateMidnight.getTime()) / msPerDay)); + + // 🧠 CRITICAL FIX: If the new math shows it shouldn't have a fine, purge the rogue record! + if (totalDelayedDays === 0 || issue.returned_date !== null) { + await fine.destroy({ transaction: t }); + purgedCount++; + continue; + } + + // Calculate tiered rates (Split values) + let withinPlanDays = totalDelayedDays; + let outsidePlanDays = 0; + + if (!isPlanActiveNow && expiryDate) { + const expiryMidnight = new Date(expiryDate.getFullYear(), expiryDate.getMonth(), expiryDate.getDate()); + if (expiryMidnight > dueDateMidnight) { + withinPlanDays = Math.max(0, Math.floor((expiryMidnight.getTime() - dueDateMidnight.getTime()) / msPerDay)); + outsidePlanDays = Math.max(0, totalDelayedDays - withinPlanDays); + } else { + withinPlanDays = 0; + outsidePlanDays = totalDelayedDays; + } + } + + const totalComputedFineAmount = (withinPlanDays * 10) + (outsidePlanDays * 20); + + // Apply changes directly to database tracking states + await fine.update({ + delayed_days: totalDelayedDays, + fine_amount: totalComputedFineAmount + }, { transaction: t }); + + updatedCount++; + } + }); + + console.log(`✅ Repair Finished! Recalculated: ${updatedCount} rows | Purged Stale Rows: ${purgedCount}`); + return { recalculated: updatedCount, purged: purgedCount }; + } + } export default new FineService(); \ No newline at end of file diff --git a/server/src/modules/issues/issue.service.ts b/server/src/modules/issues/issue.service.ts index c1011a7..32556ba 100644 --- a/server/src/modules/issues/issue.service.ts +++ b/server/src/modules/issues/issue.service.ts @@ -262,15 +262,21 @@ class IssueService { throw new AppError("Cannot change data parameters of a closed transactional history log.", httpStatus.BAD_REQUEST); } - // 📈 DYNAMIC EXTENSION STATE HANDLING + // 📈 DYNAMIC EXTENSION STATE HANDLING const finalTargetDueDate = payload.dueDate ? new Date(payload.dueDate) : new Date(issue.due_date); - const isNowSafe = finalTargetDueDate > new Date(); + + // Strip time from both objects for an accurate comparison check + const today = new Date(); + const todayMidnight = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + const targetDueDateMidnight = new Date(finalTargetDueDate.getFullYear(), finalTargetDueDate.getMonth(), finalTargetDueDate.getDate()); + + const isNowSafe = targetDueDateMidnight >= todayMidnight; const recalculatedStatus = isNowSafe ? "BORROWED" : "OVERDUE"; if (isNowSafe) { await Fine.destroy({ where: { issue_id, paid_status: false }, transaction: t }); } - + return await issueRepository.updateIssue(issue_id, { member_id: payload.memberId || issue.member_id, book_id: payload.bookId || issue.book_id,