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
65 changes: 64 additions & 1 deletion 2-Aquiis.Application/Services/PaymentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ public async Task<string> GeneratePaymentNumberAsync()

/// <summary>
/// Updates the invoice status and paid amount after a payment change.
/// Also applies late fees if invoice becomes overdue and fees haven't been applied yet.
/// </summary>
private async Task UpdateInvoiceAfterPaymentChangeAsync(Guid invoiceId)
{
Expand All @@ -420,9 +421,12 @@ private async Task UpdateInvoiceAfterPaymentChangeAsync(Guid invoiceId)

invoice.AmountPaid = totalPaid;

// Total due is the invoice amount (which includes late fees already added by ScheduledTaskService)
// Total due is the invoice amount (which includes late fees if already applied)
var totalDue = invoice.Amount;

var previousStatus = invoice.Status;
var statusChangedToOverdue = false;

// Update invoice status based on payment
// Don't change status if invoice is Cancelled or Voided
if (invoice.Status != ApplicationConstants.InvoiceStatuses.Cancelled
Expand All @@ -442,6 +446,7 @@ private async Task UpdateInvoiceAfterPaymentChangeAsync(Guid invoiceId)
if (invoice.DueOn < DateTime.Today)
{
invoice.Status = ApplicationConstants.InvoiceStatuses.Overdue;
statusChangedToOverdue = (previousStatus != ApplicationConstants.InvoiceStatuses.Overdue);
}
else
{
Expand All @@ -454,6 +459,7 @@ private async Task UpdateInvoiceAfterPaymentChangeAsync(Guid invoiceId)
if (invoice.DueOn < DateTime.Today)
{
invoice.Status = ApplicationConstants.InvoiceStatuses.Overdue;
statusChangedToOverdue = (previousStatus != ApplicationConstants.InvoiceStatuses.Overdue);
}
else
{
Expand All @@ -462,6 +468,13 @@ private async Task UpdateInvoiceAfterPaymentChangeAsync(Guid invoiceId)
}
}

// If invoice just became overdue, check if late fee should be applied
if (statusChangedToOverdue &&
(invoice.LateFeeApplied == null || !invoice.LateFeeApplied.Value))
{
await ApplyLateFeeIfEligibleAsync(invoice, organizationId);
}
Comment on lines +471 to +476
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New late-fee auto-apply behavior was added during payment processing, but the existing PaymentService unit tests don’t cover the scenario where an invoice becomes Overdue and a late fee is applied (including grace period + settings gating). Please add tests for: (1) eligible overdue invoice gets late fee applied, (2) within grace period does not apply, (3) LateFeeAutoApply disabled does not apply, (4) late fee isn’t applied twice.

Copilot uses AI. Check for mistakes.

var userId = await _userContext.GetUserIdAsync();
invoice.LastModifiedBy = userId ?? "system";
invoice.LastModifiedOn = DateTime.UtcNow;
Expand All @@ -475,5 +488,55 @@ private async Task UpdateInvoiceAfterPaymentChangeAsync(Guid invoiceId)
throw;
}
}

/// <summary>
/// Applies late fee to an invoice if eligible based on organization settings.
/// Uses the same logic as the scheduled task to ensure consistency.
/// </summary>
private async Task ApplyLateFeeIfEligibleAsync(Invoice invoice, Guid? organizationId)
{
try
{
// Get organization settings
var settings = await _context.OrganizationSettings
.FirstOrDefaultAsync(s => s.OrganizationId == organizationId);

if (settings == null || !settings.LateFeeEnabled || !settings.LateFeeAutoApply)
{
// Late fees not enabled or not set to auto-apply
return;
Comment on lines +500 to +507
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ApplyLateFeeIfEligibleAsync loads organization settings without filtering out soft-deleted rows. Other code paths filter !s.IsDeleted when reading settings; this should do the same to avoid using stale/deleted configuration.

Copilot uses AI. Check for mistakes.
}

var today = DateTime.Today;
var gracePeriodCutoff = today.AddDays(-settings.LateFeeGracePeriodDays);

// Check if invoice is past grace period
if (invoice.DueOn >= gracePeriodCutoff)
{
// Still within grace period
return;
}

// Calculate and apply late fee
var lateFee = Math.Min(invoice.Amount * settings.LateFeePercentage, settings.MaxLateFeeAmount);

invoice.LateFeeAmount = lateFee;
invoice.LateFeeApplied = true;
invoice.LateFeeAppliedOn = DateTime.UtcNow;
invoice.Amount += lateFee;
invoice.Notes = string.IsNullOrEmpty(invoice.Notes)
? $"Late fee of {lateFee:C} applied on {DateTime.Now:MMM dd, yyyy}"
: $"{invoice.Notes}\nLate fee of {lateFee:C} applied on {DateTime.Now:MMM dd, yyyy}";

Comment on lines +520 to +530
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ApplyLateFeeIfEligibleAsync applies the late fee by setting LateFeeAmount and also doing invoice.Amount += lateFee, which conflicts with other parts of the codebase that compute totals as Amount + LateFeeAmount (e.g., InvoiceService.UpdateInvoiceStatusAsync). This can lead to double-charging/incorrect balances. Please standardize late-fee accounting (either don’t mutate Amount, or stop adding LateFeeAmount elsewhere) before expanding this behavior.

Copilot uses AI. Check for mistakes.
_logger.LogInformation(
"Late fee of {LateFee:C} auto-applied to invoice {InvoiceNumber} (ID: {InvoiceId}) during payment processing",
lateFee, invoice.InvoiceNumber, invoice.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error applying late fee to invoice {InvoiceId}", invoice.Id);
// Don't throw - we don't want to fail the payment processing if late fee fails
}
}
}
}
114 changes: 48 additions & 66 deletions 2-Aquiis.Application/Services/ScheduledTaskService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,25 +117,19 @@ private async Task DoWork(CancellationToken stoppingToken)
continue;
}

// Task 1: Apply late fees to overdue invoices (if enabled)
if (settings.LateFeeEnabled && settings.LateFeeAutoApply)
{
await ApplyLateFees(dbContext, organizationId, settings, stoppingToken);
}

// Task 2: Update invoice statuses
await UpdateInvoiceStatuses(dbContext, organizationId, stoppingToken);
// Task 1: Process overdue invoices (status update + late fees)
await ProcessOverdueInvoices(dbContext, organizationId, settings, stoppingToken);

// Task 3: Send payment reminders (if enabled)
// Task 2: Send payment reminders (if enabled)
if (settings.PaymentReminderEnabled)
{
await SendPaymentReminders(dbContext, organizationId, settings, stoppingToken);
}

// Task 4: Check for expiring leases and send renewal notifications
// Task 3: Check for expiring leases and send renewal notifications
await leaseNotificationService.SendLeaseRenewalRemindersAsync(organizationId, stoppingToken);

// Task 5: Expire overdue leases using workflow service (with audit logging)
// Task 4: Expire overdue leases using workflow service (with audit logging)
var expiredLeaseCount = await ExpireOverdueLeases(scope, organizationId);
if (expiredLeaseCount > 0)
{
Expand All @@ -152,7 +146,11 @@ private async Task DoWork(CancellationToken stoppingToken)
}
}

private async Task ApplyLateFees(
/// <summary>
/// Process overdue invoices: Update status to Overdue and apply late fees in one atomic operation.
/// This prevents the race condition where status is updated but late fees are not applied.
/// </summary>
private async Task ProcessOverdueInvoices(
ApplicationDbContext dbContext,
Guid organizationId,
OrganizationSettings settings,
Expand All @@ -161,82 +159,66 @@ private async Task ApplyLateFees(
try
{
var today = DateTime.Today;
var gracePeriodCutoff = today.AddDays(-settings.LateFeeGracePeriodDays);

// Find overdue invoices that haven't been charged a late fee yet
// Find ALL pending invoices that are past due
var overdueInvoices = await dbContext.Invoices
.Include(i => i.Lease)
.Where(i => !i.IsDeleted &&
i.OrganizationId == organizationId &&
i.Status == "Pending" &&
i.DueOn < today.AddDays(-settings.LateFeeGracePeriodDays) &&
(i.LateFeeApplied == null || !i.LateFeeApplied.Value))
i.DueOn < today)
.ToListAsync(stoppingToken);

var statusUpdatedCount = 0;
var lateFeesAppliedCount = 0;

foreach (var invoice in overdueInvoices)
{
var lateFee = Math.Min(invoice.Amount * settings.LateFeePercentage, settings.MaxLateFeeAmount);

invoice.LateFeeAmount = lateFee;
invoice.LateFeeApplied = true;
invoice.LateFeeAppliedOn = DateTime.UtcNow;
invoice.Amount += lateFee;
// Always update status to Overdue
invoice.Status = "Overdue";
invoice.LastModifiedOn = DateTime.UtcNow;
invoice.LastModifiedBy = ApplicationConstants.SystemUser.Id; // Automated task
invoice.Notes = string.IsNullOrEmpty(invoice.Notes)
? $"Late fee of {lateFee:C} applied on {DateTime.Now:MMM dd, yyyy}"
: $"{invoice.Notes}\nLate fee of {lateFee:C} applied on {DateTime.Now:MMM dd, yyyy}";

_logger.LogInformation(
"Applied late fee of {LateFee:C} to invoice {InvoiceNumber} (ID: {InvoiceId}) for organization {OrganizationId}",
lateFee, invoice.InvoiceNumber, invoice.Id, organizationId);
}

if (overdueInvoices.Any())
{
await dbContext.SaveChangesAsync(stoppingToken);
_logger.LogInformation("Applied late fees to {Count} invoices for organization {OrganizationId}",
overdueInvoices.Count, organizationId);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error applying late fees for organization {OrganizationId}", organizationId);
}
}

private async Task UpdateInvoiceStatuses(ApplicationDbContext dbContext, Guid organizationId, CancellationToken stoppingToken)
{
try
{
var today = DateTime.Today;
invoice.LastModifiedBy = ApplicationConstants.SystemUser.Id;
statusUpdatedCount++;

// Apply late fee if:
// 1. Late fees are enabled and auto-apply is on
// 2. Grace period has elapsed
// 3. Late fee hasn't been applied yet
if (settings.LateFeeEnabled &&
settings.LateFeeAutoApply &&
invoice.DueOn < gracePeriodCutoff &&
(invoice.LateFeeApplied == null || !invoice.LateFeeApplied.Value))
{
var lateFee = Math.Min(invoice.Amount * settings.LateFeePercentage, settings.MaxLateFeeAmount);

invoice.LateFeeAmount = lateFee;
invoice.LateFeeApplied = true;
invoice.LateFeeAppliedOn = DateTime.UtcNow;
invoice.Amount += lateFee;
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Late fee application here mutates both invoice.LateFeeAmount and also increments invoice.Amount (invoice.Amount += lateFee). Elsewhere, InvoiceService calculates totals as invoice.Amount + (invoice.LateFeeAmount ?? 0) (e.g., 2-Aquiis.Application/Services/InvoiceService.cs:372), which will double-count late fees once this code runs. Pick a single semantic: either keep Amount as the base amount and never add the late fee to it, or treat Amount as total-due and update services/computed properties accordingly.

Suggested change
invoice.Amount += lateFee;

Copilot uses AI. Check for mistakes.
invoice.Notes = string.IsNullOrEmpty(invoice.Notes)
? $"Late fee of {lateFee:C} applied on {DateTime.Now:MMM dd, yyyy}"
: $"{invoice.Notes}\nLate fee of {lateFee:C} applied on {DateTime.Now:MMM dd, yyyy}";

// Update pending invoices that are now overdue (and haven't had late fees applied)
var newlyOverdueInvoices = await dbContext.Invoices
.Where(i => !i.IsDeleted &&
i.OrganizationId == organizationId &&
i.Status == "Pending" &&
i.DueOn < today &&
(i.LateFeeApplied == null || !i.LateFeeApplied.Value))
.ToListAsync(stoppingToken);
lateFeesAppliedCount++;

foreach (var invoice in newlyOverdueInvoices)
{
invoice.Status = "Overdue";
invoice.LastModifiedOn = DateTime.UtcNow;
invoice.LastModifiedBy = ApplicationConstants.SystemUser.Id; // Automated task
_logger.LogInformation(
"Applied late fee of {LateFee:C} to invoice {InvoiceNumber} (ID: {InvoiceId}) for organization {OrganizationId}",
lateFee, invoice.InvoiceNumber, invoice.Id, organizationId);
}
}

if (newlyOverdueInvoices.Any())
if (overdueInvoices.Any())
{
await dbContext.SaveChangesAsync(stoppingToken);
_logger.LogInformation("Updated {Count} invoices to Overdue status for organization {OrganizationId}",
newlyOverdueInvoices.Count, organizationId);
_logger.LogInformation(
"Processed {TotalCount} overdue invoice(s) for organization {OrganizationId}: {StatusUpdated} status updated, {LateFeesApplied} late fees applied",
overdueInvoices.Count, organizationId, statusUpdatedCount, lateFeesAppliedCount);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating invoice statuses for organization {OrganizationId}", organizationId);
_logger.LogError(ex, "Error processing overdue invoices for organization {OrganizationId}", organizationId);
}
}

Expand Down
58 changes: 34 additions & 24 deletions 3-Aquiis.UI.Shared/Components/Notifications/NotificationBell.razor
Original file line number Diff line number Diff line change
Expand Up @@ -9,50 +9,60 @@
<span class="spinner-border spinner-border-sm" role="status"></span>
</div>
}
else if (notifications.Count > 0)
else
{
<div class="dropdown notification-dropdown">
<div class="notification-bell">
<button class="notification-bell-button dropdown-toggle" @onclick="ToggleDropdown">
<i class="@((notificationCount > 0) ? "bi bi-bell-fill" : "bi bi-bell-slash")"></i> Notifications
<span class="notification-bell-badge">@notificationCount</span>
@if (notificationCount > 0)
{
<span class="notification-bell-badge">@notificationCount</span>
}
else
{
<span class="notification-bell-badge-inactive">0</span>
}
</button>
</div>
@if (isDropdownOpen)
{
<ul class="dropdown-menu show">
@foreach (var notification in notifications)
@if (notifications.Count > 0)
{
if(notification.IsRead)
{
<li class="dropdown-item">
<i class="bi bi-envelope-open"></i><a @onclick="() => ShowNotification(notification)">@notification.Title</a>
</li>
}
else
@foreach (var notification in notifications)
{
<li class="dropdown-item">
<i class="bi bi-envelope"></i><a @onclick="() => ShowNotification(notification)">@notification.Title</a>
</li>
if(notification.IsRead)
{
<li class="dropdown-item">
<i class="bi bi-envelope-open"></i><a @onclick="() => ShowNotification(notification)">@notification.Title</a>
</li>
}
else
{
<li class="dropdown-item">
<i class="bi bi-envelope"></i><a @onclick="() => ShowNotification(notification)">@notification.Title</a>
</li>
}
}
<hr />
<li class="dropdown-item text-center">
<a @onclick="MarkAllAsRead">Mark all as read</a>
</li>
}
else
{
<li class="dropdown-item text-center text-muted">
<i class="bi bi-inbox me-2"></i>No new notifications
</li>
<hr />
}
<hr />
<li class="dropdown-item text-center">
<a @onclick="MarkAllAsRead">Mark all as read</a>
</li>
<li class="dropdown-item text-center">
<a @onclick="GoToNotificationCenter">View all</a>
</li>
</ul>
}
</div>
} else {
<div class="notification-bell">
<button class="notification-bell-button">
<i class="bi bi-bell-slash"></i> Notifications
<span class="notification-bell-badge-inactive">@notificationCount</span>
</button>
</div>
}

<!-- Notification Modal -->
Expand Down
Loading