692 lines
32 KiB
C#
692 lines
32 KiB
C#
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<object> 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<object> 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<object> 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<object> 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<object> 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<object> 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Owner records a counter payment against a subscription. Immediately approved.
|
|
/// Fills the earliest unpaid installments; non-split plans must be settled in full.
|
|
/// </summary>
|
|
public async Task<object> 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<object> 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<PaymentTransaction>();
|
|
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
|
|
})
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<object> 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<object> 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 =
|
|
$"<p>Hi {model.MemberName},</p>" +
|
|
$"<p>You have an outstanding balance of <b>{model.Currency} {model.Balance:N2}</b> for <b>{model.PlanName}</b> at {model.GymName}.</p>" +
|
|
$"<p><a href=\"{link}\">Pay now</a></p>" +
|
|
$"<p>— {model.GymName}<br/><small>powered by GYM OS</small></p>";
|
|
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<object> 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
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<object> 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recomputes an invoice's status from its approved payment total and
|
|
/// re-allocates the installment schedule (idempotent — always derived from the total).
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
}
|