using Fitness_core_BE.DTOs.PaymentGateways; using Fitness_core_BE.DTOs.PlatformBilling; using Fitness_core_BE.Exceptions; using Fitness_core_BE.Helpers; using Fitness_core_BE.Infra.UoW; using Fitness_core_BE.Interfaces; using Fitness_core_BE.Interfaces.Services; using Fitness_core_BE.Models; using Fitness_core_BE.Models.Enums; namespace Fitness_core_BE.Services { public class PlatformBillingService : IPlatformBillingService { // The gateway a gym's saved ClientCard was onboarded through — see // ClientPaymentMethodService, which uses the same constant for onboarding. private const string GatewayName = "DirectPay"; private readonly IPlatformBillingRepository _repository; private readonly IClientStatRepository _clientStatRepository; private readonly IAccessDeviceRepo _accessDeviceRepo; private readonly IThirdPartyService _thirdPartyService; private readonly IPaymentGatewayClient _paymentGatewayClient; private readonly IPaymentRepository _paymentRepository; private readonly PlatformInvoicePaymentTokenHelper _paymentTokenHelper; private readonly IConfiguration _config; private readonly IUnitOfWork _uow; public PlatformBillingService( IPlatformBillingRepository repository, IClientStatRepository clientStatRepository, IAccessDeviceRepo accessDeviceRepo, IThirdPartyService thirdPartyService, IPaymentGatewayClient paymentGatewayClient, IPaymentRepository paymentRepository, PlatformInvoicePaymentTokenHelper paymentTokenHelper, IConfiguration config, IUnitOfWork uow) { _repository = repository; _clientStatRepository = clientStatRepository; _accessDeviceRepo = accessDeviceRepo; _thirdPartyService = thirdPartyService; _paymentGatewayClient = paymentGatewayClient; _paymentRepository = paymentRepository; _paymentTokenHelper = paymentTokenHelper; _config = config; _uow = uow; } public async Task> GetPricingPlansAsync(CancellationToken ct = default) { var plans = await _repository.GetPricingPlansAsync(ct); return plans.Select(ToDto).ToList(); } public async Task UpsertPricingPlanAsync(UpsertPricingPlanRequest request, CancellationToken ct = default) { await _uow.BeginAsync(); try { ClientPackage plan; if (request.ClientPackageId.HasValue) { plan = await _repository.GetPackageByIdAsync( request.ClientPackageId.Value, ct) ?? throw new NotFoundException("Pricing plan not found"); } else { plan = new ClientPackage { ClientPackageId = Guid.NewGuid() }; await _repository.AddPackageAsync(plan, ct); } plan.PackageName = request.PackageName; plan.PackageDescription = request.PackageDescription; plan.PerMemberRate = request.PerMemberRate; plan.MinimumMonthlyBill = request.MinimumMonthlyBill; plan.MonthlyCap = request.MonthlyCap; plan.SetupFee = request.SetupFee; plan.IncludedMemberCount = request.IncludedMemberCount; plan.MaxBillableMemberCount = request.MaxBillableMemberCount; plan.OwnGatewayFeePercent = request.OwnGatewayFeePercent; plan.OwnGatewayFeeFixed = request.OwnGatewayFeeFixed; plan.OurGatewayFeePercent = request.OurGatewayFeePercent; plan.OurGatewayFeeFixed = request.OurGatewayFeeFixed; plan.PayoutDayOfMonth = request.PayoutDayOfMonth is >= 1 and <= 28 ? request.PayoutDayOfMonth : 1; plan.PackagePrice = request.MinimumMonthlyBill; if (request.IsDefault) { // Only one plan may be default at a time. var currentDefaults = await _repository .GetOtherDefaultPackagesAsync(plan.ClientPackageId, ct); foreach (var d in currentDefaults) d.IsDefault = false; } plan.IsDefault = request.IsDefault; await _uow.CommitAsync(); return ToDto(plan); } catch { await _uow.RollbackAsync(); throw; } } public async Task GetDevicePricingAsync(CancellationToken ct = default) { var config = await GetDeviceConfigOrDefaultAsync(ct); return new DevicePricingDto { DeviceRentalFeePerMonth = config.DeviceRentalFeePerMonth, DeviceInstallationFee = config.DeviceInstallationFee, DeviceConfigureExistingFee = config.DeviceConfigureExistingFee }; } public async Task UpdateDevicePricingAsync(DevicePricingDto request, CancellationToken ct = default) { await _uow.BeginAsync(); try { var config = await _repository.GetDevicePricingConfigAsync(ct); if (config == null) { config = CreateDefaultDeviceConfig(); await _repository.AddDevicePricingConfigAsync(config, ct); } config.DeviceRentalFeePerMonth = request.DeviceRentalFeePerMonth; config.DeviceInstallationFee = request.DeviceInstallationFee; config.DeviceConfigureExistingFee = request.DeviceConfigureExistingFee; config.UpdatedAt = DateTime.UtcNow; await _uow.CommitAsync(); return request; } catch { await _uow.RollbackAsync(); throw; } } public async Task AssignPricingPlanAsync(Guid clientId, AssignPricingPlanRequest request, CancellationToken ct = default) { await _uow.BeginAsync(); try { var plan = await _repository.GetPackageByIdAsync( request.ClientPackageId, ct) ?? throw new NotFoundException("Pricing plan not found"); var client = await _repository.GetClientByIdAsync(clientId, ct) ?? throw new NotFoundException("Client not found"); var existingActive = await _repository .GetActiveClientSubscriptionsAsync(clientId, ct); foreach (var s in existingActive) s.IsActive = false; await _repository.AddClientSubscriptionAsync(new ClientSubscription { SubscriptionId = Guid.NewGuid(), FKClientId = clientId, FKClientPackageId = request.ClientPackageId, OnboardDate = DateTime.UtcNow, IsActive = true }, ct); if (!string.IsNullOrWhiteSpace(request.DeviceBillingMode)) { var existingDeviceBillings = await _repository .GetActiveDeviceBillingsAsync(clientId, ct); foreach (var d in existingDeviceBillings) d.IsActive = false; await _repository.AddDeviceBillingAsync(new ClientDeviceBilling { Id = Guid.NewGuid(), FKClientId = clientId, Mode = request.DeviceBillingMode, AgreedAmount = request.DeviceAgreedAmount ?? 0, SetupFee = request.DeviceSetupFee ?? 0, IsActive = true, AssignedAt = DateTime.UtcNow }, ct); } await _uow.SaveChangesAsync(ct); // Combined onboarding payment: (machine full price OR setup-only for rental) + // setup fee + minimum-member-subscription fee + gateway fee (may be 0). var minMemberFee = plan.MinimumMonthlyBill; var gatewayFee = ComputeGatewayFee(client, plan, minMemberFee); var extraItems = new List<(string Name, string Description, decimal Amount)>(); if (!string.IsNullOrWhiteSpace(request.DeviceBillingMode)) { if (request.DeviceBillingMode == "FullPurchase") { extraItems.Add(("Access device (full purchase)", "One-time device purchase", request.DeviceAgreedAmount ?? 0)); } if ((request.DeviceSetupFee ?? 0) > 0) { extraItems.Add(("Device setup fee", "One-time installation/setup", request.DeviceSetupFee ?? 0)); } } if (minMemberFee > 0) { extraItems.Add(("Minimum member subscription fee", $"{plan.PackageName ?? "Standard"} plan minimum bill", minMemberFee)); } if (gatewayFee > 0) { extraItems.Add(("Gateway fee", client.PaymentTrack == Models.Enums.PaymentTrack.OwnGateway ? "Own gateway fee" : "HexaFitness gateway fee", gatewayFee)); } var invoice = await CreatePlatformInvoiceAsync(clientId, "Plan setup", $"Setup for the {plan.PackageName ?? "Standard"} plan", plan.SetupFee, extraItems, ct); await _uow.CommitAsync(); return invoice; } catch { await _uow.RollbackAsync(); throw; } } public async Task ComputeMonthlyFeeAsync(Guid clientId, CancellationToken ct = default) { var client = await _repository.GetClientByIdAsync(clientId, ct) ?? throw new NotFoundException("Client not found"); var plan = await ResolvePlanForClientAsync(clientId, ct); var activeMembers = await _clientStatRepository.GetTotalCustomerCountAsync(clientId, ct); var billableMembers = plan.MaxBillableMemberCount.HasValue ? Math.Min(activeMembers, plan.MaxBillableMemberCount.Value) : activeMembers; var overageMembers = Math.Max(0, billableMembers - plan.IncludedMemberCount); decimal baseFee = plan.MinimumMonthlyBill + overageMembers * plan.PerMemberRate; if (plan.MonthlyCap.HasValue) baseFee = Math.Min(baseFee, plan.MonthlyCap.Value); var deviceFee = await ComputeDeviceFeeAsync(clientId, ct); var gatewayFee = ComputeGatewayFee(client, plan, baseFee); return new PlatformFeeBreakdownDto { ClientId = clientId, PlanName = plan.PackageName, ActiveMemberCount = activeMembers, PerMemberRate = plan.PerMemberRate, MinimumMonthlyBill = plan.MinimumMonthlyBill, MonthlyCap = plan.MonthlyCap, BaseFee = baseFee, DeviceCount = deviceFee.DeviceCount, DeviceFee = deviceFee.Fee, GatewayFee = gatewayFee, TotalFee = baseFee + deviceFee.Fee + gatewayFee }; } public async Task GenerateMonthlyInvoiceAsync(Guid clientId, CancellationToken ct = default) { var breakdown = await ComputeMonthlyFeeAsync(clientId, ct); await _uow.BeginAsync(); try { var extraItems = new List<(string, string, decimal)>(); if (breakdown.DeviceCount > 0) extraItems.Add(("Access device rental", $"{breakdown.DeviceCount} unit(s)", breakdown.DeviceFee)); if (breakdown.GatewayFee > 0) extraItems.Add(("Gateway fee", "Platform gateway fee for this billing period", breakdown.GatewayFee)); var invoice = await CreatePlatformInvoiceAsync(clientId, $"Platform fee — {breakdown.ActiveMemberCount} active member(s)", $"{breakdown.PlanName ?? "Standard"} plan — Rs. {breakdown.PerMemberRate}/member (min Rs. {breakdown.MinimumMonthlyBill})", breakdown.BaseFee, extraItems, ct); await _uow.CommitAsync(); return invoice; } catch { await _uow.RollbackAsync(); throw; } } public async Task GenerateOnboardingInvoiceAsync(Guid clientId, CancellationToken ct = default) { var plan = await ResolvePlanForClientAsync(clientId, ct); var devicePricing = await GetDeviceConfigOrDefaultAsync(ct); var hasDevice = await _repository.HasActiveDeviceAsync(clientId, ct); await _uow.BeginAsync(); try { var invoice = await CreatePlatformInvoiceAsync(clientId, "Onboarding & setup fee", $"One-time setup fee for the {plan.PackageName ?? "Standard"} plan", plan.SetupFee, hasDevice ? new[] { ("Access device installation", "One-time installation fee", devicePricing.DeviceInstallationFee) } : Array.Empty<(string, string, decimal)>(), ct); await _uow.CommitAsync(); return invoice; } catch { await _uow.RollbackAsync(); throw; } } public async Task> RunMonthlyBillingForAllClientsAsync(CancellationToken ct = default) { var startOfMonth = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc); var clientIds = await _repository .GetMonthlyBillingCandidatesAsync(startOfMonth, ct); var results = new List(); foreach (var clientId in clientIds) { // Skip clients already billed for the current period, and clients with no card on file yet // (activation not completed) — nothing meaningful to invoice for those. results.Add(await GenerateMonthlyInvoiceAsync(clientId, ct)); } return results; } // Closes the loop RunMonthlyBillingForAllClientsAsync leaves open: generates any // invoices newly due this period, then attempts (or re-polls) a real Payment-Service // charge for every still-outstanding platform invoice that has a card on file. // AttemptPaymentAsync is idempotent per invoice (IdempotencyKey below), so simply // re-calling it on a later run is a safe, correct way to both charge new invoices and // poll ones still Processing from a prior run — no separate local "pending" bookkeeping. public async Task RunAndChargeSubscriptionBillingAsync(CancellationToken ct = default) { var result = new SubscriptionChargeRunResult(); var generated = await RunMonthlyBillingForAllClientsAsync(ct); result.InvoicesGenerated = generated.Count; var outstanding = await _repository.GetInvoicesByStatusAsync("Outstanding", newestFirst: false, ct); foreach (var invoice in outstanding) { var balance = invoice.Amount - invoice.PaidAmount; if (balance <= 0) continue; var clientId = invoice.ClientSubscription.FKClientId; var card = await _repository.GetClientCardAsync(clientId, ct); if (card is null || string.IsNullOrEmpty(card.CardToken)) { result.SkippedNoCard++; continue; } // The most recent charge attempt already recorded for this invoice, if any. // Payment-Service ties AmountMinorUnits to the IdempotencyKey permanently (a // mismatched amount on replay throws), so a fresh charge always uses the // invoice's current balance, but re-polling an in-flight one must reuse the // exact amount it was created with. var existingTxn = (await _paymentRepository.GetPaymentsByClientInvoiceIdAsync(invoice.ClientInvoiceId)) .Where(t => t.RefType == "platform-subscription") .OrderByDescending(t => t.PayDate) .FirstOrDefault(); if (existingTxn?.PayStatus == "Failed") { // Payment-Service payments can never leave Failed — left Pending for the // existing manual pay-link fallback (SendClientPaymentLinkAsync) rather than // retried automatically forever. result.SkippedPreviouslyFailed++; continue; } decimal chargeAmount; if (existingTxn?.PayStatus == "Processing") { if (existingTxn.PayAmount != balance) { // The balance moved since the in-flight attempt was created (e.g. a // manual partial payment) — retrying would either overcharge or hit // Payment-Service's idempotency-conflict check. Needs manual review. result.SkippedAmountMismatch++; continue; } chargeAmount = existingTxn.PayAmount; } else { chargeAmount = balance; } AttemptPaymentResult attempt; try { attempt = await _paymentGatewayClient.AttemptPaymentAsync(clientId, new AttemptPaymentRequest { IdempotencyKey = $"platform-invoice-{invoice.ClientInvoiceId}", GatewayName = GatewayName, CardToken = card.CardToken, AmountMinorUnits = chargeAmount * 100m, Currency = "LKR", Description = $"HexaFitness platform invoice {invoice.ClientInvoiceId}" }, ct); } catch (Exception ex) { // Non-fatal for the rest of the run: this invoice is retried (idempotently) // next run, but the caller needs to see the run wasn't fully clean. Console.WriteLine($"Subscription charge attempt failed for invoice {invoice.ClientInvoiceId}: {ex.Message}"); result.Errors++; continue; } result.ChargesAttempted++; await _uow.BeginAsync(); try { if (existingTxn is null || existingTxn.PaymentReferenceId != attempt.PaymentId.ToString()) { await _paymentRepository.CreatePaymentAttemptAsync(new PaymentTransaction { PayDate = attempt.CreatedAtUtc, PayStatus = attempt.Status, PayAmount = chargeAmount, FK_ClientInvoiceId = invoice.ClientInvoiceId, FK_ClientId = clientId, BeneficiaryType = BeneficiaryType.Client, RefType = "platform-subscription", PaymentReferenceId = attempt.PaymentId.ToString(), IsActive = true, IsApproved = attempt.Status == "Completed" }); } else { existingTxn.PayStatus = attempt.Status; existingTxn.IsApproved = attempt.Status == "Completed"; await _paymentRepository.UpdatePaymentAttemptAsync(existingTxn); } if (attempt.Status == "Completed") { invoice.PaidAmount += chargeAmount; invoice.Status = invoice.PaidAmount >= invoice.Amount ? "Paid" : "Partial"; result.Paid++; } else if (attempt.Status == "Failed") { result.Failed++; } else { result.StillProcessing++; } await _uow.CommitAsync(); } catch { await _uow.RollbackAsync(); throw; } } return result; } public async Task> GetPendingInvoicesAsync(CancellationToken ct = default) { var pending = await _repository .GetInvoicesByStatusAsync("Outstanding", newestFirst: false, ct); return pending.Select(ToPendingDto).ToList(); } public async Task> GetCompletedInvoicesAsync(CancellationToken ct = default) { var completed = await _repository .GetInvoicesByStatusAsync("Paid", newestFirst: true, ct); return completed.Select(ToPendingDto).ToList(); } public async Task RecordClientInvoicePaymentAsync(Guid clientInvoiceId, decimal amount, CancellationToken ct = default) { if (amount <= 0) throw new BadRequestException("Payment amount must be greater than zero."); await _uow.BeginAsync(); try { var invoice = await _repository.GetInvoiceWithClientAsync( clientInvoiceId, includeBusinesses: true, ct) ?? throw new NotFoundException("Platform invoice not found"); var balance = invoice.Amount - invoice.PaidAmount; if (balance <= 0) throw new BadRequestException("This invoice is already fully paid."); invoice.PaidAmount += Math.Min(amount, balance); invoice.Status = invoice.PaidAmount >= invoice.Amount ? "Paid" : "Partial"; await _uow.CommitAsync(); var client = invoice.ClientSubscription.Client; if (client != null) await NotifyPaymentActivityAsync(client, invoice, sendPaymentLink: invoice.Status != "Paid"); return ToPendingDto(invoice); } catch { await _uow.RollbackAsync(); throw; } } public async Task SendClientPaymentLinkAsync(Guid clientInvoiceId, CancellationToken ct = default) { var invoice = await _repository.GetInvoiceWithClientAsync( clientInvoiceId, includeBusinesses: false, ct) ?? throw new NotFoundException("Platform invoice not found"); var client = invoice.ClientSubscription.Client ?? throw new NotFoundException("Client not found for this invoice"); if (invoice.Amount - invoice.PaidAmount <= 0) throw new BadRequestException("This invoice is already fully paid."); await NotifyPaymentActivityAsync(client, invoice, sendPaymentLink: true); return new { sent = true }; } private async Task NotifyPaymentActivityAsync(Client client, Models.ClientInvoice invoice, bool sendPaymentLink) { var balance = invoice.Amount - invoice.PaidAmount; var message = invoice.Status == "Paid" ? $"HexaFitness: payment of Rs. {invoice.PaidAmount:N2} received. Thank you!" : $"HexaFitness: Rs. {balance:N2} due on your account."; if (sendPaymentLink && balance > 0) { var token = _paymentTokenHelper.Generate(invoice.ClientInvoiceId); var baseUrl = (_config["App:FrontendBaseUrl"] ?? string.Empty).TrimEnd('/'); var link = $"{baseUrl}/pay-platform/{token}"; message += $" Pay online: {link}"; } if (!string.IsNullOrWhiteSpace(client.ClientPhone)) { try { await _thirdPartyService.SendSmsAsync(client.ClientPhone!, message: message); } catch (Exception ex) { Console.WriteLine($"Platform invoice SMS failed (non-critical): {ex.Message}"); } } } private async Task<(int DeviceCount, decimal Fee)> ComputeDeviceFeeAsync(Guid clientId, CancellationToken ct) { var devices = await _accessDeviceRepo.GetDevicesByClientAsync(clientId); var activeDeviceCount = devices.Count(d => d.IsActive); var deviceBilling = await _repository .GetLatestActiveDeviceBillingAsync(clientId, ct); if (deviceBilling != null) { // Rental contributes a recurring monthly fee; a full purchase is one-time // (already billed at assignment time) and adds nothing to the recurring fee. var fee = deviceBilling.Mode == "Rental" ? deviceBilling.AgreedAmount : 0; return (activeDeviceCount, fee); } // No per-client agreement recorded yet: fall back to the global default rate. var devicePricing = await GetDeviceConfigOrDefaultAsync(ct); return (activeDeviceCount, activeDeviceCount * devicePricing.DeviceRentalFeePerMonth); } private static decimal ComputeGatewayFee(Client client, ClientPackage plan, decimal baseAmount) { var isOwnGateway = client.PaymentTrack == Models.Enums.PaymentTrack.OwnGateway; var percent = isOwnGateway ? plan.OwnGatewayFeePercent : plan.OurGatewayFeePercent; var fixedFee = isOwnGateway ? plan.OwnGatewayFeeFixed : plan.OurGatewayFeeFixed; return Math.Round(baseAmount * percent / 100m, 2) + fixedFee; } private async Task CreatePlatformInvoiceAsync( Guid clientId, string primaryItemName, string primaryItemDescription, decimal primaryItemAmount, IEnumerable<(string Name, string Description, decimal Amount)> extraItems, CancellationToken ct) { var client = await _repository.GetClientByIdAsync(clientId, ct) ?? throw new NotFoundException("Client not found"); var subscription = await _repository .GetActiveClientSubscriptionAsync(clientId, ct); if (subscription == null) { var defaultPlan = await _repository.GetDefaultPackageAsync(ct); if (defaultPlan == null) { defaultPlan = CreateDefaultPlan(); await _repository.AddPackageAsync(defaultPlan, ct); } subscription = new ClientSubscription { SubscriptionId = Guid.NewGuid(), FKClientId = clientId, FKClientPackageId = defaultPlan.ClientPackageId, OnboardDate = DateTime.UtcNow, IsActive = true }; await _repository.AddClientSubscriptionAsync(subscription, ct); } var clientCard = await _repository.GetClientCardAsync(clientId, ct) ?? throw new BadRequestException("No card on file for this gym yet — cannot generate an invoice."); var items = new List<(string Name, string Description, decimal Amount)> { (primaryItemName, primaryItemDescription, primaryItemAmount) }; items.AddRange(extraItems); var invoice = new Models.ClientInvoice { ClientInvoiceId = Guid.NewGuid(), FKClientSubscriptionId = subscription.SubscriptionId, FKUserId = client.FKUserId, FKClientCardId = clientCard.CardId, Amount = items.Sum(i => i.Amount), PaidAmount = 0, Status = "Pending", ClientInvoiceDate = DateTime.UtcNow }; var invoiceItems = items .Select(item => new ClientInvoiceItem { ClientInvoiceItemId = Guid.NewGuid(), FKClientInvoiceId = invoice.ClientInvoiceId, ItemName = item.Name, ItemDescription = item.Description, ItemPrice = item.Amount, CreatedAt = DateTime.UtcNow }) .ToList(); await _repository.AddInvoiceAsync(invoice, invoiceItems, ct); return invoice; } private async Task ResolvePlanForClientAsync(Guid clientId, CancellationToken ct) { return await _repository.GetActivePackageForClientAsync(clientId, ct) ?? await _repository.GetDefaultPackageAsync(ct) ?? CreateDefaultPlan(); } private static ClientPackage CreateDefaultPlan() { return new ClientPackage { ClientPackageId = Guid.NewGuid(), PackageName = "Standard", PackageDescription = "Default platform pricing plan", PerMemberRate = 0, MinimumMonthlyBill = 0, MonthlyCap = null, SetupFee = 0, IsDefault = true, IsActive = true }; } private async Task GetDeviceConfigOrDefaultAsync(CancellationToken ct) { return await _repository.GetDevicePricingConfigAsync(ct) ?? CreateDefaultDeviceConfig(); } private static DevicePricingConfig CreateDefaultDeviceConfig() => new() { DevicePricingConfigId = Guid.NewGuid() }; private static PricingPlanDto ToDto(ClientPackage plan) => new() { ClientPackageId = plan.ClientPackageId, PackageName = plan.PackageName, PackageDescription = plan.PackageDescription, PerMemberRate = plan.PerMemberRate, MinimumMonthlyBill = plan.MinimumMonthlyBill, MonthlyCap = plan.MonthlyCap, SetupFee = plan.SetupFee, IncludedMemberCount = plan.IncludedMemberCount, MaxBillableMemberCount = plan.MaxBillableMemberCount, OwnGatewayFeePercent = plan.OwnGatewayFeePercent, OwnGatewayFeeFixed = plan.OwnGatewayFeeFixed, OurGatewayFeePercent = plan.OurGatewayFeePercent, OurGatewayFeeFixed = plan.OurGatewayFeeFixed, PayoutDayOfMonth = plan.PayoutDayOfMonth, IsDefault = plan.IsDefault, IsActive = plan.IsActive }; private static PendingPlatformInvoiceDto ToPendingDto(Models.ClientInvoice invoice) { var now = DateTime.UtcNow; return new PendingPlatformInvoiceDto { ClientInvoiceId = invoice.ClientInvoiceId, ClientId = invoice.ClientSubscription.FKClientId, ClientName = invoice.ClientSubscription.Client?.ClientName, City = invoice.ClientSubscription.Client?.ClientBusinesses?.FirstOrDefault()?.City, Amount = invoice.Amount, PaidAmount = invoice.PaidAmount, Status = invoice.Status, ClientInvoiceDate = invoice.ClientInvoiceDate, DaysOverdue = Math.Max(0, (int)(now - invoice.ClientInvoiceDate).TotalDays) }; } } }