first commit
This commit is contained in:
@@ -0,0 +1,752 @@
|
||||
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<List<PricingPlanDto>> GetPricingPlansAsync(CancellationToken ct = default)
|
||||
{
|
||||
var plans = await _repository.GetPricingPlansAsync(ct);
|
||||
|
||||
return plans.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<PricingPlanDto> 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<DevicePricingDto> GetDevicePricingAsync(CancellationToken ct = default)
|
||||
{
|
||||
var config = await GetDeviceConfigOrDefaultAsync(ct);
|
||||
return new DevicePricingDto
|
||||
{
|
||||
DeviceRentalFeePerMonth = config.DeviceRentalFeePerMonth,
|
||||
DeviceInstallationFee = config.DeviceInstallationFee,
|
||||
DeviceConfigureExistingFee = config.DeviceConfigureExistingFee
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<DevicePricingDto> 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<Models.ClientInvoice> 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<PlatformFeeBreakdownDto> 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<Models.ClientInvoice> 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<Models.ClientInvoice> 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<List<Models.ClientInvoice>> 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<Models.ClientInvoice>();
|
||||
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<SubscriptionChargeRunResult> 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<List<PendingPlatformInvoiceDto>> GetPendingInvoicesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var pending = await _repository
|
||||
.GetInvoicesByStatusAsync("Outstanding", newestFirst: false, ct);
|
||||
|
||||
return pending.Select(ToPendingDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<PendingPlatformInvoiceDto>> GetCompletedInvoicesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var completed = await _repository
|
||||
.GetInvoicesByStatusAsync("Paid", newestFirst: true, ct);
|
||||
|
||||
return completed.Select(ToPendingDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<PendingPlatformInvoiceDto> 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<object> 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<Models.ClientInvoice> 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<ClientPackage> 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<DevicePricingConfig> 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user