Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions client/src/features/issues/components/TransactionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ export const TransactionModal = ({ isOpen, onClose, onSubmit, editingRecord }: T
<div className="bg-slate-900 p-5 text-white flex justify-between items-center border-b border-slate-light/10">
<div>
<h3 className="text-[11px] font-bold uppercase tracking-wider text-slate-400">Circulation Terminal</h3>
<h2 className="text-xl font-bold text-white tracking-tight mt-0.5">{editingRecord ? "Adjust Loan Parameters" : "Issue New Book Voucher"}</h2>
<h2 className="text-xl font-bold text-white tracking-tight mt-0.5">{editingRecord ? "Edit issue details" : "Issue New Book"}</h2>
</div>
<button
type="button"
Expand All @@ -222,7 +222,7 @@ export const TransactionModal = ({ isOpen, onClose, onSubmit, editingRecord }: T

{/* 1. MEMBER LOOKUP INPUT SECTION */}
<div className="relative">
<label className="text-[11px] font-bold text-slate-400 uppercase tracking-wider block mb-1.5">Member Registration</label>
<label className="text-[11px] font-bold text-slate-400 uppercase tracking-wider block mb-1.5">Member Name</label>
<div className="relative">
<User className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" size={16} />
<input
Expand Down Expand Up @@ -422,7 +422,7 @@ export const TransactionModal = ({ isOpen, onClose, onSubmit, editingRecord }: T
disabled={isSubmissionBlocked}
className="px-4 py-2.5 text-xs font-bold uppercase tracking-wider text-white bg-slate-900 hover:bg-slate-800 disabled:bg-slate-100 disabled:text-slate-400 disabled:cursor-not-allowed shadow-xs rounded-xl transition-all cursor-pointer"
>
{editingRecord ? "Save Parameters" : "Confirm Issue"}
{editingRecord ? "Save Changes" : "Create Issue"}
</button>
</div>
</form>
Expand Down
2 changes: 1 addition & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
125 changes: 114 additions & 11 deletions server/src/modules/fines/fine.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
Expand All @@ -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
Expand All @@ -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();
12 changes: 9 additions & 3 deletions server/src/modules/issues/issue.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading