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.Payment { public class PaymentService : IPaymentService { private readonly IPaymentRepository _paymentRepository; private readonly ICustomerInvoiceRepository _customerInvoiceRepository; private readonly IClientInvoiceRepository _clientInvoiceRepository; private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository; private readonly ICustomerRepository _customerRepository; private readonly IReceiptService _receiptService; private readonly IThirdPartyService _thirdPartyService; private readonly IAccessDeviceService _accessDeviceService; private readonly InvoicePaymentTokenHelper _invoicePaymentTokenHelper; private readonly IConfiguration _config; private readonly UserRoleHelper _userRoleHelper; private readonly IUnitOfWork _uow; public PaymentService( IPaymentRepository paymentRepository, ICustomerInvoiceRepository customerInvoiceRepository, IClientInvoiceRepository clientInvoiceRepository, ICustomerSubscriptionRepository customerSubscriptionRepository, ICustomerRepository customerRepository, IReceiptService receiptService, IThirdPartyService thirdPartyService, IAccessDeviceService accessDeviceService, InvoicePaymentTokenHelper invoicePaymentTokenHelper, IConfiguration config, UserRoleHelper userRoleHelper, IUnitOfWork uow) { _paymentRepository = paymentRepository; _customerInvoiceRepository = customerInvoiceRepository; _clientInvoiceRepository = clientInvoiceRepository; _customerSubscriptionRepository = customerSubscriptionRepository; _customerRepository = customerRepository; _receiptService = receiptService; _thirdPartyService = thirdPartyService; _accessDeviceService = accessDeviceService; _invoicePaymentTokenHelper = invoicePaymentTokenHelper; _config = config; _userRoleHelper = userRoleHelper; _uow = uow; } public async Task CreateCustomerPayment(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); Guid customerInvoiceId = DataExtractor.GetRequiredGuid(data, "customerInvoiceId"); decimal payAmount = DataExtractor.GetRequiredDecimal(data, "payAmount"); var invoice = await _customerInvoiceRepository.GetCustomerInvoiceById(customerInvoiceId) ?? throw new NotFoundException($"Customer invoice {customerInvoiceId} not found"); Guid? cardId = null; var cardIdStr = DataExtractor.GetOptionalString(data, "cardId"); if (!string.IsNullOrEmpty(cardIdStr) && Guid.TryParse(cardIdStr, out var parsedCardId)) cardId = parsedCardId; var attempt = new PaymentTransaction { PayDate = DateTime.UtcNow, PayStatus = "Pending", PayAmount = payAmount, PayNote = DataExtractor.GetOptionalString(data, "payNote"), FK_CardId = cardId, FK_CustomerInvoiceId = customerInvoiceId, FK_ClientId = invoice.FKClientId, FK_CustomerId = invoice.CustomerSubscription?.FKCustomerId, BeneficiaryType = BeneficiaryType.Customer, RefType = DataExtractor.GetOptionalString(data, "refType") ?? "invoice", BankRefNo = DataExtractor.GetOptionalString(data, "bankRefNo"), PaymentReferenceId = DataExtractor.GetOptionalString(data, "paymentReferenceId"), IsActive = true, IsApproved = false }; var result = await _paymentRepository.CreatePaymentAttemptAsync(attempt); await _uow.CommitAsync(); return new { success = true, message = "Payment attempt created", payment = result }; } catch { await _uow.RollbackAsync(); throw; } } public async Task CreateClientPayment(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); Guid clientInvoiceId = DataExtractor.GetRequiredGuid(data, "clientInvoiceId"); decimal payAmount = DataExtractor.GetRequiredDecimal(data, "payAmount"); var invoice = await _clientInvoiceRepository.GetClientInvoiceById(clientInvoiceId) ?? throw new NotFoundException($"Client invoice {clientInvoiceId} not found"); Guid? cardId = null; var cardIdStr = DataExtractor.GetOptionalString(data, "cardId"); if (!string.IsNullOrEmpty(cardIdStr) && Guid.TryParse(cardIdStr, out var parsedCardId)) cardId = parsedCardId; var attempt = new PaymentTransaction { PayDate = DateTime.UtcNow, PayStatus = "Pending", PayAmount = payAmount, PayNote = DataExtractor.GetOptionalString(data, "payNote"), FK_CardId = cardId, FK_ClientInvoiceId = clientInvoiceId, FK_ClientId = invoice.ClientSubscription?.FKClientId, BeneficiaryType = BeneficiaryType.Client, RefType = DataExtractor.GetOptionalString(data, "refType") ?? "invoice", BankRefNo = DataExtractor.GetOptionalString(data, "bankRefNo"), PaymentReferenceId = DataExtractor.GetOptionalString(data, "paymentReferenceId"), IsActive = true, IsApproved = false }; var result = await _paymentRepository.CreatePaymentAttemptAsync(attempt); await _uow.CommitAsync(); return new { success = true, message = "Payment attempt created", payment = result }; } catch { await _uow.RollbackAsync(); throw; } } public async Task GetPaymentById(object payload, HttpContext httpContext) { try { var data = DeserializePayload.Deserialize(payload); Guid attemptId = DataExtractor.GetRequiredGuid(data, "attemptId"); var payment = await _paymentRepository.GetPaymentByIdAsync(attemptId) ?? throw new NotFoundException($"Payment {attemptId} not found"); return payment; } catch { throw; } } public async Task GetPaymentsByCustomerInvoice(object payload, HttpContext httpContext) { try { var data = DeserializePayload.Deserialize(payload); Guid customerInvoiceId = DataExtractor.GetRequiredGuid(data, "customerInvoiceId"); var payments = await _paymentRepository.GetPaymentsByCustomerInvoiceIdAsync(customerInvoiceId); return payments; } catch { throw; } } public async Task GetPaymentsByClientInvoice(object payload, HttpContext httpContext) { try { var data = DeserializePayload.Deserialize(payload); Guid clientInvoiceId = DataExtractor.GetRequiredGuid(data, "clientInvoiceId"); var payments = await _paymentRepository.GetPaymentsByClientInvoiceIdAsync(clientInvoiceId); return payments; } catch { throw; } } public async Task ApprovePayment(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); Guid attemptId = DataExtractor.GetRequiredGuid(data, "attemptId"); var payment = await _paymentRepository.GetPaymentByIdAsync(attemptId) ?? throw new NotFoundException($"Payment {attemptId} not found"); payment.IsApproved = true; payment.PayStatus = "Approved"; await _paymentRepository.UpdatePaymentAttemptAsync(payment); if (payment.FK_CustomerInvoiceId.HasValue) { // Roll the invoice status up from the approved total (Pending/Partial/Paid) // and re-allocate the schedule, rather than blindly marking "Paid". await _uow.SaveChangesAsync(); await RollupCustomerInvoiceAsync(payment.FK_CustomerInvoiceId.Value); } else if (payment.FK_ClientInvoiceId.HasValue) { await _clientInvoiceRepository.UpdateClientInvoiceStatus(payment.FK_ClientInvoiceId.Value, "Paid"); } await _uow.CommitAsync(); if (payment.FK_ClientInvoiceId.HasValue) { // Post-commit, best-effort: email/SMS the gym owner a receipt PDF for their // platform-fee payment. Never let a notification failure affect the approval. try { var receipt = await _receiptService.BuildForClientInvoiceAsync(payment.FK_ClientInvoiceId.Value, payment.PayAmount); await _receiptService.SendReceiptAsync(receipt); } catch (Exception ex) { Console.WriteLine($"Client invoice receipt notification failed (non-critical): {ex.Message}"); } } return new { success = true, message = "Payment approved", payment }; } catch { await _uow.RollbackAsync(); throw; } } /// /// Owner records a counter payment against a subscription. Immediately approved. /// Fills the earliest unpaid installments; non-split plans must be settled in full. /// public async Task RecordSubscriptionPayment(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); Guid subscriptionId = DataExtractor.GetRequiredGuid(data, "subscriptionId"); decimal amount = DataExtractor.GetRequiredDecimal(data, "amount"); string? note = DataExtractor.GetOptionalString(data, "note"); string paymentMethod = DataExtractor.GetRequiredString(data, "paymentMethod"); string? bankRefNo = DataExtractor.GetOptionalString(data, "bankRefNo"); if (paymentMethod != "Cash" && paymentMethod != "Bank") throw new ArgumentException("paymentMethod must be 'Cash' or 'Bank'"); if (amount <= 0) throw new ArgumentException("Payment amount must be greater than zero"); var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(subscriptionId) ?? throw new NotFoundException("Subscription not found"); var plan = subscription.CustomerPlan ?? throw new NotFoundException("Subscription plan not found"); // Authorization: the subscription's plan must belong to the caller's gym. Guid clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); if (plan.FKClientId != clientId) throw new UnauthorizedException("This subscription does not belong to your gym"); var invoice = await _customerInvoiceRepository.GetInvoiceBySubscriptionIdAsync(subscriptionId); if (invoice == null) { // GetInvoiceBySubscriptionIdAsync also returns null while a renewal invoice // is awaiting approval (its only invoices are unapproved and/or RolledOver // right now) -- that is not a legacy subscription, and self-healing here // would silently create a duplicate, uncarried-forward invoice. Only // self-heal a genuine "never had an invoice" subscription. if (await _customerInvoiceRepository.HasPendingApprovalInvoiceAsync(subscriptionId)) throw new BadRequestException( "This member's renewal is awaiting approval. Approve, change plan, or reject it before recording a payment."); // Self-heal legacy subscriptions that predate automatic invoicing. invoice = await _customerInvoiceRepository.CreateCustomerInvoiceFromSubscription(subscriptionId); await _uow.SaveChangesAsync(); } var alreadyPaid = await _paymentRepository.GetApprovedPaidAmountAsync(invoice.CustomerInvoiceId); var balance = invoice.Amount - alreadyPaid; if (amount > balance) throw new ArgumentException($"Amount exceeds the outstanding balance ({balance:0.##})"); if (!plan.AllowSplitPayments && amount != balance) throw new ArgumentException("This plan must be paid in full; split payments are not enabled"); var attempt = new PaymentTransaction { PayDate = DateTime.UtcNow, PayStatus = "Approved", PayAmount = amount, PayNote = note, FK_CustomerInvoiceId = invoice.CustomerInvoiceId, FK_ClientId = invoice.FKClientId, FK_CustomerId = subscription.FKCustomerId, BeneficiaryType = BeneficiaryType.Customer, RefType = "manual", PaymentMethod = paymentMethod, BankRefNo = paymentMethod == "Bank" ? bankRefNo : null, IsActive = true, IsApproved = true }; var result = await _paymentRepository.CreatePaymentAttemptAsync(attempt); await _uow.SaveChangesAsync(); var summary = await RollupCustomerInvoiceAsync(invoice.CustomerInvoiceId); await _uow.CommitAsync(); // Post-commit, best-effort: text the member and email them a receipt PDF. // Never let a notification failure affect the recorded payment. try { var receipt = await _receiptService.BuildForInvoiceAsync(invoice.CustomerInvoiceId, amount); await _receiptService.SendReceiptAsync(receipt); } catch (Exception ex) { Console.WriteLine($"Receipt notification failed (non-critical): {ex.Message}"); } // Post-commit, best-effort: once the invoice is fully settled, undo whatever // overdue lock put the member's fingerprint access on hold -- the owner just // collected the money, they shouldn't also have to remember to hit the separate // unlock endpoint. SetCustomerAccessAsync already carries this onto every // co-member on a shared group/couple subscription, not just the payer. if (summary.Status == "Paid") { try { var customer = await _customerRepository.GetCustomerById(subscription.FKCustomerId); if (customer != null) { var accessStatus = await _accessDeviceService.GetAccessStatusAsync(clientId, customer.FKUserId); if (accessStatus?.DesiredAccessState == "Disabled") await _accessDeviceService.SetCustomerAccessAsync(clientId, subscription.FKCustomerId, enabled: true, reason: null); } } catch (Exception ex) { Console.WriteLine($"Auto-unlock after payment failed (non-critical): {ex.Message}"); } } return new { success = true, invoiceId = invoice.CustomerInvoiceId, invoiceAmount = summary.InvoiceAmount, paidAmount = summary.Paid, balance = summary.Balance, status = summary.Status, payment = result }; } catch { await _uow.RollbackAsync(); throw; } } public async Task GetSubscriptionPayments(object payload, HttpContext httpContext) { var data = DeserializePayload.Deserialize(payload); Guid subscriptionId = DataExtractor.GetRequiredGuid(data, "subscriptionId"); var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(subscriptionId) ?? throw new NotFoundException("Subscription not found"); Guid clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); if (subscription.CustomerPlan == null || subscription.CustomerPlan.FKClientId != clientId) throw new UnauthorizedException("This subscription does not belong to your gym"); var invoice = await _customerInvoiceRepository.GetInvoiceBySubscriptionIdAsync(subscriptionId); decimal invoiceAmount = invoice?.Amount ?? subscription.CustomerPlan.PlanPrice + (subscription.CustomerPlan.HasOnboardingFee ? subscription.CustomerPlan.OnboardingFeeAmount : 0m); var payments = invoice != null ? await _paymentRepository.GetPaymentsByCustomerInvoiceIdAsync(invoice.CustomerInvoiceId) : new List(); var installments = await _customerSubscriptionRepository.GetInstallmentsAsync(subscriptionId); decimal paid = payments.Where(p => p.IsApproved == true).Sum(p => p.PayAmount); return new { invoiceId = invoice?.CustomerInvoiceId, invoiceAmount, paidAmount = paid, balance = invoiceAmount - paid, status = paid >= invoiceAmount && invoiceAmount > 0 ? "Paid" : paid > 0 ? "Partial" : "Pending", installments = installments.Select(i => new { i.SubscriptionInstallmentId, i.InstallmentNo, i.DueDate, i.Amount, i.PaidAmount, i.Status }), payments = payments.Select(p => new { p.AttemptId, p.PayDate, p.PayStatus, p.PayAmount, p.PayNote, p.RefType, p.PaymentMethod, p.BankRefNo, p.IsApproved }) }; } /// /// Owner-triggered "Charge now" against the member's consented auto-charge card. /// No live payment gateway is wired up anywhere in this backend yet (see /// ActivationService's own deferred platform-billing charge) — this records the attempt /// and leaves it Pending, exactly like the rest of the app defers real settlement. Replace /// the body of this method with a real PayHere/Stripe charge call once a gateway exists; /// until then the owner falls back to SendPaymentLink or RecordSubscriptionPayment. /// public async Task AttemptAutoCharge(Guid customerInvoiceId, HttpContext httpContext) { await _uow.BeginAsync(); try { var invoice = await _customerInvoiceRepository.GetCustomerInvoiceById(customerInvoiceId) ?? throw new NotFoundException("Invoice not found"); Guid clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); if (invoice.FKClientId != clientId) throw new UnauthorizedException("This invoice does not belong to your gym"); var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(invoice.FKCustomerSubscriptionId) ?? throw new NotFoundException("Subscription not found"); if (!subscription.AutoChargeEnabled || subscription.FKAutoChargeCardId == null) throw new BadRequestException("Auto-deduct isn't enabled for this member. Send a payment link or mark as paid instead."); var card = await _customerRepository.GetCardByIdAsync(subscription.FKAutoChargeCardId.Value); if (card == null || !card.ConsentGiven) throw new BadRequestException("This member hasn't consented to auto-deduct. Send a payment link or mark as paid instead."); var alreadyPaid = await _paymentRepository.GetApprovedPaidAmountAsync(invoice.CustomerInvoiceId); var balance = invoice.Amount - alreadyPaid; if (balance <= 0) throw new BadRequestException("This invoice is already fully paid."); var attempt = new PaymentTransaction { PayDate = DateTime.UtcNow, PayStatus = "Pending", PayAmount = balance, FK_CardId = card.JobId, FK_CustomerInvoiceId = invoice.CustomerInvoiceId, FK_ClientId = invoice.FKClientId, FK_CustomerId = subscription.FKCustomerId, BeneficiaryType = BeneficiaryType.Customer, RefType = "auto", IsActive = true, IsApproved = false }; var result = await _paymentRepository.CreatePaymentAttemptAsync(attempt); await _uow.CommitAsync(); return new { success = true, status = "Pending", message = "Charge attempt recorded — pending confirmation.", payment = result }; } catch { await _uow.RollbackAsync(); throw; } } /// /// Emails/texts the member a link to the public pay-by-link page so they can pay this /// invoice online themselves — the other fallback alongside AttemptAutoCharge/RecordSubscriptionPayment. /// public async Task SendPaymentLink(Guid customerInvoiceId, HttpContext httpContext) { Guid clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); var model = await _receiptService.BuildForInvoiceAsync(customerInvoiceId, requireClientId: clientId); if (model.Balance <= 0) throw new BadRequestException("This invoice is already fully paid."); if (string.IsNullOrWhiteSpace(model.MemberEmail) && string.IsNullOrWhiteSpace(model.MemberPhone)) throw new BadRequestException("This member has no phone or email on file to send the link to."); string token = _invoicePaymentTokenHelper.Generate(customerInvoiceId); string baseUrl = (_config["App:FrontendBaseUrl"] ?? string.Empty).TrimEnd('/'); string link = $"{baseUrl}/pay/{token}"; bool emailSent = false; bool smsSent = false; if (!string.IsNullOrWhiteSpace(model.MemberEmail)) { try { string html = $"

Hi {model.MemberName},

" + $"

You have an outstanding balance of {model.Currency} {model.Balance:N2} for {model.PlanName} at {model.GymName}.

" + $"

Pay now

" + $"

— {model.GymName}
powered by GYM OS

"; await _thirdPartyService.SendEmailAsync(model.MemberEmail!, $"Payment link — {model.GymName}", html); emailSent = true; } catch (Exception ex) { Console.WriteLine($"Payment link email failed (non-critical): {ex.Message}"); } } if (!string.IsNullOrWhiteSpace(model.MemberPhone)) { try { string sms = $"Pay {model.Currency} {model.Balance:N2} for {model.PlanName} at {model.GymName}: {link}"; var smsResult = await _thirdPartyService.SendSmsAsync(model.MemberPhone!, message: sms); smsSent = smsResult.StatusCode is >= 200 and < 300; } catch (Exception ex) { Console.WriteLine($"Payment link SMS failed (non-critical): {ex.Message}"); } } if (!emailSent && !smsSent) throw new BadRequestException("Couldn't send the payment link by email or SMS. Check that notifications are configured."); string via = emailSent && smsSent ? "email and SMS" : emailSent ? "email" : "SMS"; return new { success = true, message = $"Payment link sent via {via}" }; } public async Task GetInvoiceByPaymentToken(string token) { var invoiceId = _invoicePaymentTokenHelper.ValidateAndGetInvoiceId(token) ?? throw new NotFoundException("This payment link is invalid or has expired."); var model = await _receiptService.BuildForInvoiceAsync(invoiceId); return new { memberName = model.MemberName, gymName = model.GymName, planName = model.PlanName, amount = model.InvoiceAmount, balance = model.Balance, currency = model.Currency }; } /// /// Public, one-time self-serve payment from the pay-by-link page. Since the member is /// completing this live (not a background auto-charge), it's approved immediately — same /// treatment as RecordSubscriptionPayment. cardToken is a mock value today; this is the /// seam where a real gateway checkout replaces the stub later. /// public async Task ChargeInvoiceByPaymentToken(string token, object payload) { var invoiceId = _invoicePaymentTokenHelper.ValidateAndGetInvoiceId(token) ?? throw new NotFoundException("This payment link is invalid or has expired."); await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); DataExtractor.GetRequiredString(data, "cardToken"); var invoice = await _customerInvoiceRepository.GetCustomerInvoiceById(invoiceId) ?? throw new NotFoundException("Invoice not found"); var alreadyPaid = await _paymentRepository.GetApprovedPaidAmountAsync(invoice.CustomerInvoiceId); var balance = invoice.Amount - alreadyPaid; if (balance <= 0) throw new BadRequestException("This invoice is already fully paid."); var attempt = new PaymentTransaction { PayDate = DateTime.UtcNow, PayStatus = "Approved", PayAmount = balance, PayNote = "Paid online via payment link", FK_CustomerInvoiceId = invoice.CustomerInvoiceId, FK_ClientId = invoice.FKClientId, FK_CustomerId = invoice.CustomerSubscription?.FKCustomerId, BeneficiaryType = BeneficiaryType.Customer, RefType = "link", IsActive = true, IsApproved = true }; await _paymentRepository.CreatePaymentAttemptAsync(attempt); await _uow.SaveChangesAsync(); var summary = await RollupCustomerInvoiceAsync(invoice.CustomerInvoiceId); await _uow.CommitAsync(); try { var receipt = await _receiptService.BuildForInvoiceAsync(invoice.CustomerInvoiceId, balance); await _receiptService.SendReceiptAsync(receipt); } catch (Exception ex) { Console.WriteLine($"Receipt notification failed (non-critical): {ex.Message}"); } return new { success = true, invoiceId = invoice.CustomerInvoiceId, paidAmount = summary.Paid, balance = summary.Balance, status = summary.Status }; } catch { await _uow.RollbackAsync(); throw; } } /// /// Recomputes an invoice's status from its approved payment total and /// re-allocates the installment schedule (idempotent — always derived from the total). /// private async Task<(decimal InvoiceAmount, decimal Paid, decimal Balance, string Status)> RollupCustomerInvoiceAsync(Guid customerInvoiceId) { var invoice = await _customerInvoiceRepository.GetCustomerInvoiceById(customerInvoiceId) ?? throw new NotFoundException("Invoice not found"); decimal paid = await _paymentRepository.GetApprovedPaidAmountAsync(customerInvoiceId); string status = paid >= invoice.Amount && invoice.Amount > 0 ? "Paid" : paid > 0 ? "Partial" : "Pending"; await _customerInvoiceRepository.UpdateCustomerInvoiceStatus(customerInvoiceId, status); // Distribute the total across installments in order. var installments = await _customerSubscriptionRepository.GetInstallmentsAsync(invoice.FKCustomerSubscriptionId); decimal remaining = paid; foreach (var installment in installments) { decimal fill = Math.Min(remaining, installment.Amount); installment.PaidAmount = fill; installment.Status = fill >= installment.Amount ? "Paid" : fill > 0 ? "Partial" : "Pending"; remaining -= fill; } return (invoice.Amount, paid, invoice.Amount - paid, status); } } }