Files
Core_Be/Repos/CustomerInvoiceRepository.cs
T
Dhananjaya99 bffc9b1686 first commit
2026-09-28 11:44:26 +05:30

570 lines
29 KiB
C#

using Fitness_core_BE.Domain;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Fitness_core_BE;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class CustomerInvoiceRepository(TenantDbContext context) : ICustomerInvoiceRepository
{
// Shared with BuildAndAddInvoice (writes it), RepointAndRecalculateInvoiceAsync (preserves
// it by name) and GetCarriedForwardAmountsAsync (reads it) -- one constant so the three
// sites can't drift apart.
private const string PreviousBalanceItemName = "Previous Balance";
private readonly TenantDbContext _context = context;
public async Task<CustomerInvoice> CreateCustomerInvoiceFromSubscription(
Guid subscriptionId, bool chargePayment = true, decimal carriedOverBalance = 0m, bool includeOnboardingFee = true)
{
// Idempotency: invoices are now created automatically at subscription time,
// so a second call (e.g. via the legacy function-handler route) must not duplicate.
// Not used by the daily renewal sweep (CreateRenewalInvoiceAsync) -- under the
// renewal-approval model the same subscription legitimately gets a new invoice every
// cycle, which this guard would otherwise block. Credit/Applied don't count as "the"
// invoice either -- a Credit-only subscription (e.g. right after a downgrade) still
// needs a real invoice built the next time one is due, which is what actually consumes
// that open credit via ConsumeOpenCreditAsync inside BuildAndAddInvoice.
var existingInvoice = await _context.CustomerInvoices
.Where(i => i.FKCustomerSubscriptionId == subscriptionId
&& i.Status != "Cancelled" && i.Status != "RolledOver"
&& i.Status != "Credit" && i.Status != "Applied")
.OrderByDescending(i => i.CustomerInvoiceDate)
.FirstOrDefaultAsync();
if (existingInvoice != null)
{
return existingInvoice;
}
var subscription = await LoadSubscriptionForInvoiceAsync(subscriptionId);
var subItems = await LoadActiveSubItemsAsync(subscriptionId);
// Only consume credit when there's actually something to apply it to -- a
// chargePayment=false invoice is already Rs. 0, so burning the customer's open credit
// on it would just destroy that value instead of applying it to anything.
var openCredit = chargePayment
? await ConsumeOpenCreditAsync(subscription.TenantCustomerLink.CentralUserId, subscription.CustomerPlan.FKClientId)
: 0m;
return BuildAndAddInvoice(
subscription, subItems, chargePayment, carriedOverBalance, openCredit, includeOnboardingFee, isActive: true);
}
// Guard-free sibling of CreateCustomerInvoiceFromSubscription for the daily renewal
// sweep: unlike a normal subscription (one invoice, created once), the same
// CustomerSubscription now persists across many renewal cycles and legitimately gets a
// new invoice each time, so the "an invoice already exists" idempotency guard above does
// not apply here. Duplicate-renewal protection instead lives in the renewal candidate
// query itself (SubscriptionRenewalRunner excludes subscriptions with an
// already-pending, unapproved invoice). Renewals never re-bill the one-time onboarding
// fee, and the new invoice starts IsActive=false pending staff approval.
public async Task<CustomerInvoice> CreateRenewalInvoiceAsync(
Guid subscriptionId, decimal carriedOverBalance, CancellationToken ct = default)
{
var subscription = await LoadSubscriptionForInvoiceAsync(subscriptionId, ct);
var subItems = await LoadActiveSubItemsAsync(subscriptionId, ct);
var openCredit = await ConsumeOpenCreditAsync(
subscription.TenantCustomerLink.CentralUserId, subscription.CustomerPlan.FKClientId, ct);
return BuildAndAddInvoice(
subscription, subItems, chargePayment: true, carriedOverBalance, openCredit,
includeOnboardingFee: false, isActive: false);
}
private async Task<Fitness_core_BE.Models.CustomerSubscription> LoadSubscriptionForInvoiceAsync(
Guid subscriptionId, CancellationToken ct = default)
{
var subscription = await _context.CustomerSubscriptions
.Include(cs => cs.CustomerPlan)
.Include(cs => cs.TenantCustomerLink)
.FirstOrDefaultAsync(cs => cs.SubscriptionId == subscriptionId, ct);
if (subscription == null)
{
throw new Exception("Subscription not found");
}
return subscription;
}
private Task<List<CustomerSubItem>> LoadActiveSubItemsAsync(Guid subscriptionId, CancellationToken ct = default)
{
return _context.CustomerSubItems
.Where(si => si.FKCustomerSubscriptionId == subscriptionId && si.IsActive)
.ToListAsync(ct);
}
// Shared itemization/discount/carry-forward logic behind both invoice-creation entry
// points above. Adds the invoice and its line items to the context but does not save --
// callers control the transaction boundary.
private CustomerInvoice BuildAndAddInvoice(
Fitness_core_BE.Models.CustomerSubscription subscription,
List<CustomerSubItem> subItems,
bool chargePayment,
decimal carriedOverBalance,
decimal openCredit,
bool includeOnboardingFee,
bool isActive)
{
decimal onboardingFee = includeOnboardingFee && subscription.CustomerPlan.HasOnboardingFee
? subscription.CustomerPlan.OnboardingFeeAmount
: 0m;
decimal subtotal = subscription.CustomerPlan.PlanPrice + onboardingFee;
subtotal += subItems.Sum(si => si.SubItemPrice);
decimal discountAmount = DiscountCalculator.ComputeDiscountAmount(
subtotal, subscription.DiscountType, subscription.DiscountValue);
decimal totalAmount = subtotal - discountAmount;
// Already-earned debt from a prior period isn't discountable goods -- add it after
// the discount, not into the subtotal it's computed from.
if (carriedOverBalance > 0)
totalAmount += carriedOverBalance;
// Owner chose not to charge (e.g. comped/waived membership) — keep the itemized
// breakdown for record-keeping but the invoice owes nothing.
if (!chargePayment)
totalAmount = 0m;
// Any open credit (e.g. from a prior mid-cycle downgrade) applies automatically to
// the next invoice, whatever kind it is -- floored at 0 rather than going negative;
// this is a regular invoice, not a credit note, so unused credit beyond this bill
// simply stays open for the one after it (ConsumeOpenCreditAsync already marked the
// whole thing "Applied" up front, so a same-transaction leftover is not re-split here).
decimal creditApplied = Math.Min(openCredit, Math.Max(0m, totalAmount));
if (creditApplied > 0)
totalAmount -= creditApplied;
var customerInvoice = new CustomerInvoice
{
CustomerInvoiceId = Guid.NewGuid(),
FKCustomerSubscriptionId = subscription.SubscriptionId,
FKClientId = subscription.CustomerPlan.FKClientId,
FKUserId = subscription.TenantCustomerLink.CentralUserId,
Amount = totalAmount,
// A Rs. 0 invoice (chargePayment=false, a 100% discount, or credit covering the
// whole bill) has nothing left to collect -- leaving it "Pending" would show up
// as outstanding debt forever with no way to mark it paid.
Status = totalAmount == 0m ? "Paid" : "Pending",
IsActive = isActive,
CustomerInvoiceDate = DateTime.UtcNow
};
_context.CustomerInvoices.Add(customerInvoice);
AddInvoiceLineItems(customerInvoice.CustomerInvoiceId, subscription, subItems, onboardingFee, discountAmount);
if (carriedOverBalance > 0)
{
_context.CustomerInvoiceItems.Add(new CustomerInvoiceItem
{
CustomerInvoiceItemId = Guid.NewGuid(),
FKCustomerInvoiceId = customerInvoice.CustomerInvoiceId,
ItemName = PreviousBalanceItemName,
ItemDescription = "Outstanding balance carried over from the previous subscription period",
ItemPrice = carriedOverBalance,
CreatedAt = DateTime.UtcNow
});
}
if (creditApplied > 0)
{
_context.CustomerInvoiceItems.Add(new CustomerInvoiceItem
{
CustomerInvoiceItemId = Guid.NewGuid(),
FKCustomerInvoiceId = customerInvoice.CustomerInvoiceId,
ItemName = "Credit Applied",
ItemDescription = "Credit balance from a prior plan change, applied to this invoice",
ItemPrice = -creditApplied,
CreatedAt = DateTime.UtcNow
});
}
return customerInvoice;
}
// Finds every still-open Credit invoice for this customer at this gym and consumes all
// of it in one shot (marks each "Applied"), returning the total as a positive amount for
// the caller to subtract from whatever invoice is being built right now. Credit invoices
// are created by CreatePlanChangeInvoiceAsync when a downgrade leaves the customer having
// overpaid -- this is the other half of that: applying it the next time they're billed.
private async Task<decimal> ConsumeOpenCreditAsync(Guid userId, Guid clientId, CancellationToken ct = default)
{
var creditInvoices = await _context.CustomerInvoices
.Where(i => i.FKUserId == userId && i.FKClientId == clientId && i.Status == "Credit")
.ToListAsync(ct);
if (creditInvoices.Count == 0)
return 0m;
decimal totalCredit = 0m;
foreach (var invoice in creditInvoices)
{
totalCredit += -invoice.Amount; // Amount is stored negative on a Credit invoice
invoice.Status = "Applied";
}
return totalCredit;
}
// Plan/onboarding-fee/sub-item/discount line items -- shared by BuildAndAddInvoice and
// RepointAndRecalculateInvoiceAsync (the "Previous Balance" carry-forward item is handled
// separately by each caller, since RepointAndRecalculateInvoiceAsync must leave an
// existing one untouched rather than recreate it).
private void AddInvoiceLineItems(
Guid customerInvoiceId,
Fitness_core_BE.Models.CustomerSubscription subscription,
List<CustomerSubItem> subItems,
decimal onboardingFee,
decimal discountAmount)
{
_context.CustomerInvoiceItems.Add(new CustomerInvoiceItem
{
CustomerInvoiceItemId = Guid.NewGuid(),
FKCustomerInvoiceId = customerInvoiceId,
ItemName = subscription.CustomerPlan.PlanName,
ItemDescription = subscription.CustomerPlan.PlanDescription,
ItemPrice = subscription.CustomerPlan.PlanPrice,
CreatedAt = DateTime.UtcNow
});
if (onboardingFee > 0)
{
_context.CustomerInvoiceItems.Add(new CustomerInvoiceItem
{
CustomerInvoiceItemId = Guid.NewGuid(),
FKCustomerInvoiceId = customerInvoiceId,
ItemName = "Onboarding Fee",
ItemDescription = "One-time gym onboarding fee",
ItemPrice = onboardingFee,
CreatedAt = DateTime.UtcNow
});
}
foreach (var subItem in subItems)
{
_context.CustomerInvoiceItems.Add(new CustomerInvoiceItem
{
CustomerInvoiceItemId = Guid.NewGuid(),
FKCustomerInvoiceId = customerInvoiceId,
ItemName = subItem.SubItemName,
ItemDescription = subItem.SubItemDescription,
ItemPrice = subItem.SubItemPrice,
CreatedAt = DateTime.UtcNow
});
}
if (discountAmount > 0)
{
_context.CustomerInvoiceItems.Add(new CustomerInvoiceItem
{
CustomerInvoiceItemId = Guid.NewGuid(),
FKCustomerInvoiceId = customerInvoiceId,
ItemName = "Discount",
ItemDescription = subscription.DiscountType == "Percentage"
? $"{subscription.DiscountValue}% discount"
: $"Flat discount of Rs. {subscription.DiscountValue}",
ItemPrice = -discountAmount,
CreatedAt = DateTime.UtcNow
});
}
}
public Task<bool> HasPendingApprovalInvoiceAsync(Guid subscriptionId)
{
return _context.CustomerInvoices.AnyAsync(i =>
i.FKCustomerSubscriptionId == subscriptionId && i.IsActive == false && i.Status != "Cancelled");
}
// Renewal-approval workflow: the unapproved invoices awaiting staff review.
public async Task<List<CustomerInvoice>> GetUnapprovedInvoicesByClientIdAsync(Guid clientId)
{
return await _context.CustomerInvoices
.Include(i => i.CustomerSubscription)
.ThenInclude(cs => cs.CustomerPlan)
.Include(i => i.CustomerSubscription)
.ThenInclude(cs => cs.TenantCustomerLink)
.Where(i => i.FKClientId == clientId && i.IsActive == false && i.Status != "Cancelled")
.OrderByDescending(i => i.CustomerInvoiceDate)
.ToListAsync();
}
// Regular invoices tab: one row per customer (their latest approved, currently-relevant
// invoice) instead of every invoice they've ever had. "RolledOver" is excluded alongside
// "Cancelled": once an invoice's balance has been rolled into a newer one (see
// SubscriptionRenewalRunner), it's superseded -- surfacing it here as "the latest" would
// double-count that balance against the new invoice's own carried-forward line item.
public async Task<List<CustomerInvoice>> GetLatestApprovedInvoicePerCustomerAsync(Guid clientId)
{
// Cancelled is the only status excluded up front -- RolledOver and not-yet-approved
// (IsActive=false) invoices are still real candidates below when they're the most
// recent thing on the customer's active subscription (mid-renewal: the old cycle's
// invoice is RolledOver and the new one is awaiting approval, so *neither* would
// pass the old "IsActive && not RolledOver" filter, leaving nothing on that
// subscription to show and wrongly falling back to a long-superseded invoice).
var all = await _context.CustomerInvoices
.Include(i => i.CustomerSubscription)
.ThenInclude(cs => cs.CustomerPlan)
.Include(i => i.CustomerSubscription)
.ThenInclude(cs => cs.TenantCustomerLink)
.Where(i => i.FKClientId == clientId && i.Status != "Cancelled")
.ToListAsync();
return all
.GroupBy(i => i.CustomerSubscription.FKCustomerId)
.Select(g =>
{
// A customer has at most one active subscription -- whatever their most
// recent invoice on it is (even RolledOver or still-unapproved) is more
// representative of where they actually stand than a fully-settled invoice
// from a subscription that's been inactive for months.
var onActiveSubscription = g
.Where(i => i.CustomerSubscription.IsActive)
.OrderByDescending(i => i.CustomerInvoiceDate)
.FirstOrDefault();
if (onActiveSubscription != null)
return onActiveSubscription;
// No active subscription (e.g. removed from the gym, or between plan
// changes) -- fall back to their last genuinely-collectible invoice.
return g.Where(i => i.IsActive && i.Status != "RolledOver")
.OrderByDescending(i => i.CustomerInvoiceDate)
.FirstOrDefault();
})
.Where(i => i != null)
.Select(i => i!)
.OrderByDescending(i => i.CustomerInvoiceDate)
.ToList();
}
// Change-Plan approval action: repoints a pending renewal invoice at the freshly-created
// replacement subscription and recalculates the "new round" portion from the new plan's
// price -- any carried-forward "Previous Balance" line item is left exactly as-is.
public async Task RepointAndRecalculateInvoiceAsync(
Guid customerInvoiceId, Guid newSubscriptionId, CancellationToken ct = default)
{
var invoice = await _context.CustomerInvoices
.FirstOrDefaultAsync(i => i.CustomerInvoiceId == customerInvoiceId, ct);
if (invoice == null)
{
throw new Exception("Invoice not found");
}
var previousBalanceItem = await _context.CustomerInvoiceItems
.FirstOrDefaultAsync(item =>
item.FKCustomerInvoiceId == customerInvoiceId && item.ItemName == PreviousBalanceItemName, ct);
decimal carriedOverBalance = previousBalanceItem?.ItemPrice ?? 0m;
var staleItems = await _context.CustomerInvoiceItems
.Where(item => item.FKCustomerInvoiceId == customerInvoiceId && item.ItemName != PreviousBalanceItemName)
.ToListAsync(ct);
_context.CustomerInvoiceItems.RemoveRange(staleItems);
var newSubscription = await LoadSubscriptionForInvoiceAsync(newSubscriptionId, ct);
var subItems = await LoadActiveSubItemsAsync(newSubscriptionId, ct);
// Approval-flow invoices never re-bill the one-time onboarding fee.
decimal subtotal = newSubscription.CustomerPlan.PlanPrice + subItems.Sum(si => si.SubItemPrice);
decimal discountAmount = DiscountCalculator.ComputeDiscountAmount(
subtotal, newSubscription.DiscountType, newSubscription.DiscountValue);
decimal newRoundAmount = Math.Max(0m, subtotal - discountAmount);
invoice.FKCustomerSubscriptionId = newSubscriptionId;
invoice.Amount = newRoundAmount + carriedOverBalance;
invoice.IsActive = true;
AddInvoiceLineItems(customerInvoiceId, newSubscription, subItems, onboardingFee: 0m, discountAmount);
}
public async Task<Dictionary<Guid, decimal>> GetCarriedForwardAmountsAsync(List<Guid> invoiceIds)
{
if (invoiceIds.Count == 0)
return new Dictionary<Guid, decimal>();
return await _context.CustomerInvoiceItems
.Where(item => invoiceIds.Contains(item.FKCustomerInvoiceId) && item.ItemName == PreviousBalanceItemName)
.GroupBy(item => item.FKCustomerInvoiceId)
.Select(g => new { InvoiceId = g.Key, Total = g.Sum(item => item.ItemPrice) })
.ToDictionaryAsync(x => x.InvoiceId, x => x.Total);
}
public async Task<CustomerInvoice?> GetInvoiceBySubscriptionIdAsync(Guid subscriptionId)
{
// IsActive filter: once a renewal invoice exists awaiting approval, this must keep
// resolving to the real, still-collectible prior invoice (manual payments post
// against that one) rather than the pending-approval invoice. Defaults true for
// every other invoice, so this is a no-op for subscriptions with no pending renewal.
// RolledOver is excluded too: once its balance has been carried into a newer invoice,
// it is no longer the invoice to collect against -- without this, a payment recorded
// during the approval-pending window would land on the superseded invoice while the
// new invoice's "Previous Balance" line (which already counts that same debt) never
// reflects the payment. Credit/Applied are excluded for the same reason as RolledOver
// -- a Credit invoice has a negative Amount and nothing to collect (it's owed to the
// customer, not by them), so it must never be "the invoice" RecordSubscriptionPayment
// tries to charge against.
return await _context.CustomerInvoices
.Where(i => i.FKCustomerSubscriptionId == subscriptionId
&& i.Status != "Cancelled" && i.Status != "RolledOver"
&& i.Status != "Credit" && i.Status != "Applied" && i.IsActive)
.OrderByDescending(i => i.CustomerInvoiceDate)
.FirstOrDefaultAsync();
}
public async Task<CustomerInvoice?> GetCustomerInvoiceById(Guid customerInvoiceId)
{
return await _context.CustomerInvoices
.Include(i => i.CustomerSubscription)
.ThenInclude(cs => cs.CustomerPlan)
.Include(i => i.CustomerSubscription)
.ThenInclude(cs => cs.TenantCustomerLink)
.FirstOrDefaultAsync(i => i.CustomerInvoiceId == customerInvoiceId);
}
public async Task<List<CustomerInvoice>> GetCustomerInvoicesByCustomerId(Guid customerId)
{
return await _context.CustomerInvoices
.Include(i => i.CustomerSubscription)
.ThenInclude(cs => cs.CustomerPlan)
.Include(i => i.CustomerSubscription)
.ThenInclude(cs => cs.TenantCustomerLink)
.Where(i => i.CustomerSubscription.FKCustomerId == customerId)
// Newest cycle first, oldest last -- ordered by the subscription's own
// RenewalDate/ExpiryDate (its true place in the renewal chain), not raw
// CustomerInvoiceDate: a backdated OnboardDate anchors that date to the
// backdated cycle, so the very first invoice can carry a date that's numerically
// *later* than several genuinely more recent renewals, putting it at the top of
// a CustomerInvoiceDate-sorted list instead of the bottom.
.OrderByDescending(i => i.CustomerSubscription.RenewalDate
?? i.CustomerSubscription.ExpiryDate
?? i.CustomerInvoiceDate)
.ToListAsync();
}
public async Task<List<CustomerInvoice>> GetCustomerInvoicesByClientId(Guid clientId)
{
// Cancelled invoices are superseded/orphaned (e.g. left behind when a subscription
// was reassigned) and must not appear on the payments screen or count toward its
// Total Billed / Outstanding totals -- same exclusion already used everywhere else
// this repo reads invoices (GetCustomerInvoicesBySubscription/GetCustomerInvoiceById).
// RolledOver invoices are excluded the same way: their balance has already been
// carried into a newer invoice, so counting both would double the outstanding total.
return await _context.CustomerInvoices
.Include(i => i.CustomerSubscription)
.ThenInclude(cs => cs.CustomerPlan)
.Include(i => i.CustomerSubscription)
.ThenInclude(cs => cs.TenantCustomerLink)
.Where(i => i.FKClientId == clientId && i.Status != "Cancelled" && i.Status != "RolledOver")
.OrderByDescending(i => i.CustomerInvoiceDate)
.ToListAsync();
}
public async Task<CustomerInvoice> UpdateCustomerInvoiceStatus(Guid customerInvoiceId, string status)
{
var customerInvoice = await _context.CustomerInvoices.FindAsync(customerInvoiceId);
if (customerInvoice == null)
{
throw new Exception("CustomerInvoice not found");
}
customerInvoice.Status = status;
return customerInvoice;
}
public async Task<CustomerInvoice> CreateSupplementalInvoiceAsync(Guid subscriptionId, string itemName, string? itemDescription, decimal amount)
{
var subscription = await _context.CustomerSubscriptions
.Include(cs => cs.CustomerPlan)
.Include(cs => cs.TenantCustomerLink)
.FirstOrDefaultAsync(cs => cs.SubscriptionId == subscriptionId);
if (subscription == null)
{
throw new Exception("Subscription not found");
}
var invoice = new CustomerInvoice
{
CustomerInvoiceId = Guid.NewGuid(),
FKCustomerSubscriptionId = subscriptionId,
FKClientId = subscription.CustomerPlan.FKClientId,
FKUserId = subscription.TenantCustomerLink.CentralUserId,
Amount = amount,
Status = amount == 0m ? "Paid" : "Pending",
CustomerInvoiceDate = DateTime.UtcNow
};
_context.CustomerInvoices.Add(invoice);
_context.CustomerInvoiceItems.Add(new CustomerInvoiceItem
{
CustomerInvoiceItemId = Guid.NewGuid(),
FKCustomerInvoiceId = invoice.CustomerInvoiceId,
ItemName = itemName,
ItemDescription = itemDescription,
ItemPrice = amount,
CreatedAt = DateTime.UtcNow
});
return invoice;
}
// Plan-change settlement invoice: `amount` is the signed diff between the new plan's
// price and whatever the customer already paid toward the old one. Nets out any
// already-open credit first (e.g. a prior downgrade), same as every other invoice path.
public async Task<CustomerInvoice> CreatePlanChangeInvoiceAsync(
Guid subscriptionId, string itemName, decimal amount, CancellationToken ct = default)
{
var subscription = await LoadSubscriptionForInvoiceAsync(subscriptionId, ct);
var openCredit = await ConsumeOpenCreditAsync(
subscription.TenantCustomerLink.CentralUserId, subscription.CustomerPlan.FKClientId, ct);
var finalAmount = amount - openCredit;
// Negative = the customer now has a credit (this settlement alone, or combined with
// extra prior credit, exceeds what they owe) -- ConsumeOpenCreditAsync already marked
// the old credit rows "Applied", so this new row becomes the one open credit invoice
// going forward. Zero reuses the same "nothing left to collect" rule as every other
// invoice path.
var status = finalAmount < 0 ? "Credit" : finalAmount == 0m ? "Paid" : "Pending";
var invoice = new CustomerInvoice
{
CustomerInvoiceId = Guid.NewGuid(),
FKCustomerSubscriptionId = subscriptionId,
FKClientId = subscription.CustomerPlan.FKClientId,
FKUserId = subscription.TenantCustomerLink.CentralUserId,
Amount = finalAmount,
Status = status,
CustomerInvoiceDate = DateTime.UtcNow
};
_context.CustomerInvoices.Add(invoice);
_context.CustomerInvoiceItems.Add(new CustomerInvoiceItem
{
CustomerInvoiceItemId = Guid.NewGuid(),
FKCustomerInvoiceId = invoice.CustomerInvoiceId,
ItemName = itemName,
ItemDescription = status == "Credit"
? "Credit from switching to a lower-priced plan mid-cycle -- applied automatically to your next invoice."
: "Balance due from switching to a higher-priced plan mid-cycle.",
ItemPrice = finalAmount,
CreatedAt = DateTime.UtcNow
});
return invoice;
}
public async Task<decimal> GetApprovedPaymentTotalAsync(Guid invoiceId, CancellationToken ct = default)
{
return await _context.PaymentTransactions
.AsNoTracking()
.Where(payment =>
payment.FK_CustomerInvoiceId == invoiceId &&
payment.IsApproved == true &&
payment.IsActive == true)
.Select(payment => (decimal?)payment.PayAmount)
.SumAsync(ct) ?? 0m;
}
}
}