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 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 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 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> 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 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 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 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 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> 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> 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> GetCarriedForwardAmountsAsync(List invoiceIds) { if (invoiceIds.Count == 0) return new Dictionary(); 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 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 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> 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> 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 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 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 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 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; } } }