-
Notifications
You must be signed in to change notification settings - Fork 0
v1.0.1: Hotfix Release #25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
| { | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
| { | ||
|
|
@@ -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 | ||
| { | ||
|
|
@@ -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); | ||
| } | ||
|
|
||
| var userId = await _userContext.GetUserIdAsync(); | ||
| invoice.LastModifiedBy = userId ?? "system"; | ||
| invoice.LastModifiedOn = DateTime.UtcNow; | ||
|
|
@@ -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
|
||
| } | ||
|
|
||
| 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
|
||
| _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 | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -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) | ||||
| { | ||||
|
|
@@ -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, | ||||
|
|
@@ -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; | ||||
|
||||
| invoice.Amount += lateFee; |
There was a problem hiding this comment.
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.