570 lines
29 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|