using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.Login; using GB5Shared.EventLogPublish; using GB5Shared.GB5Constant; using GB5Shared.GenerateAutoNumber; using GB5Shared.QueryExecutor; using GB5Shared.Resource.Response; using GoodBooks.PAY.PAYBLL.Abstractions; using GoodBooks.PAY.PAYBLL.Factory; using GoodBooks.PAY.PAYBLL.Integration; using GoodBooks.PAY.PAYBLL.Vault; using GoodBooks.PAY.PAYDAL.CustomCode.Gateway; using GoodBooks.PAY.PAYDAL.CustomCode.Loyalty; using GoodBooks.PAY.PAYDAL.CustomCode.Order; using GoodBooks.PAY.PAYDAL.DTO.Order; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using static GB5Shared.GB5Constant.Constant; namespace GoodBooks.PAY.PAYBLL.Checkout { // ───────────────────────────────────────────────────────────────────────── // PayOrderBLL // // Orchestrates the full payment order lifecycle: // InitiatePayOrder — 20-step flow (validation → DB insert → gateway → DB update) // GetPayOrder — single read (NOT_REQUIRED cache — transactional data) // GetPayOrderList — paged read (NOT_REQUIRED cache) // // Two separate DbTransaction windows are intentionally used: // Tx-1: AutoNumber + InsertPayOrder (committed before gateway call) // Tx-2: InsertPayTransaction + UpdatePayOrderStatus // The gateway call sits OUTSIDE any transaction to avoid long-held connections. // // EntityConstant: OBJECTTPAYORDER = -1382000004 // EventTypeConstant: INITIATEPAYORDEREVENTTYPEID = -1382000008 // ───────────────────────────────────────────────────────────────────────── public class PayOrderBLL : IPayOrderBLL { private readonly IPayOrderDAL _payOrderDal; private readonly IPayTransactionDAL _payTxnDal; private readonly IPayGatewayConfigDAL _gwConfigDal; private readonly ILoyaltyLedgerDAL _loyaltyLedgerDal; private readonly ICustomerLoyaltyEnrollmentDAL _enrollmentDal; private readonly IPaymentGatewayFactory _gatewayFactory; private readonly IVaultService _vault; private readonly ISalesCouponService _couponService; private readonly AutoNumber _autoNumber; private readonly EventLogPublish _eventLogPublish; private readonly IQueryExecutor _queryExecutor; private readonly ILogger _logger; // 1 loyalty point = ₹0.01 monetary value private const decimal LoyaltyPointConversionRate = 0.01m; public PayOrderBLL( IPayOrderDAL payOrderDal, IPayTransactionDAL payTxnDal, IPayGatewayConfigDAL gwConfigDal, ILoyaltyLedgerDAL loyaltyLedgerDal, ICustomerLoyaltyEnrollmentDAL enrollmentDal, IPaymentGatewayFactory gatewayFactory, IVaultService vault, ISalesCouponService couponService, AutoNumber autoNumber, EventLogPublish eventLogPublish, IQueryExecutor queryExecutor, ILogger logger) { _payOrderDal = payOrderDal; _payTxnDal = payTxnDal; _gwConfigDal = gwConfigDal; _loyaltyLedgerDal = loyaltyLedgerDal; _enrollmentDal = enrollmentDal; _gatewayFactory = gatewayFactory; _vault = vault; _couponService = couponService; _autoNumber = autoNumber; _eventLogPublish = eventLogPublish; _queryExecutor = queryExecutor; _logger = logger; } // ── Get single pay order ────────────────────────────────────────────── public async Task GetPayOrder(int payOrderId, LoginDTO login, CancellationToken ct) { try { return await _payOrderDal.GetPayOrder(payOrderId, login).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "GetPayOrder failed for PayOrderId {PayOrderId}", payOrderId); throw; } } // ── Paged list of pay orders ────────────────────────────────────────── public async Task GetPayOrderList( int pageOffset, int pageSize, LoginDTO login, CancellationToken ct) { try { return await _payOrderDal.GetPayOrderList(pageOffset, pageSize, login) .ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "GetPayOrderList failed for TenantId {TenantId}", login.ClientId); throw; } } // ── Payment history for one source document (Entitlement↔Payment, §9.1) ── public async Task GetPayOrdersBySourceDoc( int sourceDocType, int sourceDocId, LoginDTO login, CancellationToken ct) { try { return await _payOrderDal.GetPayOrdersBySourceDoc(sourceDocType, sourceDocId, login) .ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "GetPayOrdersBySourceDoc failed for SourceDocType {SourceDocType} SourceDocId {SourceDocId}", sourceDocType, sourceDocId); throw; } } // ── InitiatePayOrder — 20-step orchestration ────────────────────────── // // Steps: // 1. Validate inputs // 2. Load PayGatewayConfig (typed DTO, not JSON) // 3. Fetch API credentials from Vault // 4. Duplicate-order guard // 5. Loyalty points validation (if redemption requested) // 5b. Coupon validation via ISalesCouponService (if a CouponCode was supplied) // 6. Compute NetPayableAmt // 7. BEGIN Tx-1 // 8. AutoNumber for PayOrderId // 9. Build PayOrderNo // 10. Build and insert PayOrderDTO (status=1 Created) // 11. COMMIT Tx-1 // 12. Call gateway.CreateOrderAsync [outside tx] // 13. Gateway failure → mark order Failed (status=5), throw // 14. BEGIN Tx-2 // 15. AutoNumber for PayTransactionId // 16. Insert PayTransactionDTO (status=1 Initiated) // 17. Update TPAYORDER: set GatewayPayOrderId + status=2 Pending // 18. COMMIT Tx-2 // 19. Fire-and-forget event log (non-fatal) // 20. Return serialized PayOrderDTO with CheckoutUrl public async Task InitiatePayOrder( InitiatePayOrderRequest request, LoginDTO login, CancellationToken ct) { if (request is null) throw new ArgumentNullException(nameof(request)); // ── Step 1: Input validation ────────────────────────────────────── if (string.IsNullOrWhiteSpace(request.GatewayCode)) throw new ArgumentException("GatewayCode is required.", nameof(request.GatewayCode)); if (request.SourceDocType <= 0) throw new ArgumentException("SourceDocType must be greater than zero.", nameof(request.SourceDocType)); if (request.SourceDocId <= 0) throw new ArgumentException("SourceDocId must be greater than zero.", nameof(request.SourceDocId)); if (request.OrderAmt <= 0) throw new ArgumentException("OrderAmt must be greater than zero.", nameof(request.OrderAmt)); // ── Step 2: Load gateway config ─────────────────────────────────── var config = await _gwConfigDal.GetPayGatewayConfigForWebhook( request.GatewayCode, login.ClientId, login).ConfigureAwait(false); if (config is null) throw new InvalidOperationException( $"No active payment gateway configuration found for gateway '{request.GatewayCode}'."); // ── Step 3: Fetch Vault secrets ─────────────────────────────────── var apiKey = await _vault.GetSecretAsync(config.ApiKeyIdVaultKey, ct).ConfigureAwait(false); var apiSecret = await _vault.GetSecretAsync(config.ApiSecretVaultKey, ct).ConfigureAwait(false); // ── Step 4: Duplicate-order guard ───────────────────────────────── int activeCount = await _payOrderDal.CountActiveOrdersForSourceDoc( request.SourceDocType, request.SourceDocId, login).ConfigureAwait(false); if (activeCount > 0) throw new InvalidOperationException( "Active payment order already exists for this document."); // ── Step 5: Loyalty points validation ──────────────────────────── decimal loyaltyDiscAmt = 0m; if (request.LoyaltyPointsToRedeem > 0) { decimal availablePoints = await _loyaltyLedgerDal .GetAvailablePoints(request.LoyaltyEnrollmentId, login) .ConfigureAwait(false); if (availablePoints < request.LoyaltyPointsToRedeem) throw new InvalidOperationException( $"Insufficient loyalty points. Available: {availablePoints}, Requested: {request.LoyaltyPointsToRedeem}."); loyaltyDiscAmt = request.LoyaltyPointsToRedeem * LoyaltyPointConversionRate; } // ── Step 5b: Coupon validation ──────────────────────────────────── // A supplied CouponCode must resolve to a real, currently-valid coupon — an // invalid/expired code is rejected outright rather than silently checking out // with no discount, since the customer explicitly asked for it to apply. decimal couponDiscAmt = 0m; if (!string.IsNullOrWhiteSpace(request.CouponCode)) { var couponResult = await _couponService.ValidateCouponAsync( request.CouponCode, request.CustomerId, request.OrderAmt, login, ct) .ConfigureAwait(false); if (couponResult is null || !couponResult.IsValid) throw new InvalidOperationException( couponResult?.ErrorMessage ?? $"Coupon code '{request.CouponCode}' is invalid or expired."); couponDiscAmt = couponResult.DiscountAmt; } // ── Step 6: Compute net payable ─────────────────────────────────── // Minimum billable amount is ₹0.01 — gateways reject zero-amount orders. decimal netPayableAmt = Math.Max(0.01m, request.OrderAmt - loyaltyDiscAmt - couponDiscAmt); // ── Steps 7-11: Tx-1 — DB insert ──────────────────────────────── int payOrderId; string payOrderNo; var tx1 = await _queryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { // Step 8: AutoNumber for PayOrderId var orderAutoNum = await _autoNumber.GetAutoNumber(1, "PAYORDER", login) .ConfigureAwait(false); payOrderId = orderAutoNum.StartNumber; // Step 9: Build PayOrderNo payOrderNo = $"PAY-{DateTime.UtcNow:yyyyMMdd}-{payOrderId:D6}"; // Step 10: Build and insert PayOrderDTO (OrderStatus=1 Created) var orderDto = new PayOrderDTO { PayOrderId = payOrderId, PayOrderNo = payOrderNo, SourceDocType = request.SourceDocType, SourceDocId = request.SourceDocId, CustomerId = request.CustomerId, CustomerName = request.CustomerName, CustomerEmail = request.CustomerEmail, CustomerPhone = request.CustomerPhone, OrderCurrency = request.OrderCurrency, OrderAmt = request.OrderAmt, LoyaltyDiscAmt = loyaltyDiscAmt, CouponDiscAmt = couponDiscAmt, NetPayableAmt = netPayableAmt, PayGatewayConfId = config.PayGatewayConfId, OrderStatus = 1, // Created PaidAmt = 0m, Version = 0, Status = 1, SortOrder = 9999, CreatedById = login.UserId, CreatedOn = DateTime.UtcNow, ModifiedById = login.UserId, ModifiedOn = DateTime.UtcNow, TenantId = login.ClientId }; await _payOrderDal.InsertPayOrder(orderDto, login, tx1).ConfigureAwait(false); // Step 11: Commit Tx-1 await _queryExecutor.CommitAsync(tx1).ConfigureAwait(false); } catch { await _queryExecutor.RollbackAsync(tx1).ConfigureAwait(false); throw; } // ── Step 12: Gateway call [outside any transaction] ─────────────── var gateway = _gatewayFactory.GetGateway(request.GatewayCode); var createReq = new CreateGatewayOrderRequest( PayOrderId : payOrderId, PayOrderNo : payOrderNo, Amount : netPayableAmt, Currency : request.OrderCurrency, CustomerEmail : request.CustomerEmail, CustomerPhone : request.CustomerPhone, CallbackUrl : request.CallbackUrl, ApiKey : apiKey, ApiSecret : apiSecret); GatewayOrderResult gwResult = await gateway.CreateOrderAsync(createReq, ct) .ConfigureAwait(false); // ── Step 13: Gateway failure — mark order Failed (status=5) ─────── if (!gwResult.Success) { try { await _payOrderDal.UpdatePayOrderStatus( payOrderId, 5, null, 0m, login).ConfigureAwait(false); } catch (Exception failEx) { _logger.LogWarning(failEx, "Failed to mark PayOrder {PayOrderId} as Failed after gateway error", payOrderId); } throw new InvalidOperationException( $"Payment gateway order creation failed: {gwResult.ErrorMessage}"); } // ── Steps 14-18: Tx-2 — record gateway response ────────────────── var tx2 = await _queryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { // Step 15: AutoNumber for PayTransactionId var txnAutoNum = await _autoNumber.GetAutoNumber(1, "PAYTRANSACTION", login) .ConfigureAwait(false); int payTransactionId = txnAutoNum.StartNumber; // Step 16: Insert PayTransactionDTO (TxnStatus=1 Initiated) var txnDto = new PayTransactionDTO { PayTransactionId = payTransactionId, PayOrderId = payOrderId, TxnStatus = 1, // Initiated TxnCurrency = request.OrderCurrency, TxnAmt = netPayableAmt, TxnInitiatedOn = DateTime.UtcNow, Version = 0, Status = 1, SortOrder = 9999, CreatedById = login.UserId, CreatedOn = DateTime.UtcNow, ModifiedById = login.UserId, ModifiedOn = DateTime.UtcNow, TenantId = login.ClientId }; await _payTxnDal.InsertPayTransaction(txnDto, login, tx2).ConfigureAwait(false); // Step 17: Update TPAYORDER — set GatewayPayOrderId + OrderStatus=2 (Pending) await _payOrderDal.UpdatePayOrderStatus( payOrderId, 2, gwResult.GatewayOrderId, 0m, login, tx2).ConfigureAwait(false); // Step 18: Commit Tx-2 await _queryExecutor.CommitAsync(tx2).ConfigureAwait(false); } catch { await _queryExecutor.RollbackAsync(tx2).ConfigureAwait(false); throw; } // ── Step 19: Fire-and-forget event log (non-fatal) ──────────────── try { await _eventLogPublish.PublishEventLogAsync( "Payment Order Initiated", new { PayOrderId = payOrderId, PayOrderNo = payOrderNo, GatewayCode = request.GatewayCode }, Constant.EventTypeConstant.INITIATEPAYORDEREVENTTYPEID, 0, login).ConfigureAwait(false); } catch (Exception ex) { _logger.LogWarning(ex, "Event log publish failed for PayOrder {PayOrderId}", payOrderId); } // ── Step 19b: Record coupon usage (fire-and-forget, non-fatal) ──── // Only reached once the order has genuinely been created against a gateway — // a coupon is "used" the moment checkout succeeds, not merely validated. if (!string.IsNullOrWhiteSpace(request.CouponCode)) { try { await _couponService.RecordCouponUsageAsync( request.CouponCode, request.CustomerId, payOrderId, login, ct) .ConfigureAwait(false); } catch (Exception ex) { _logger.LogWarning(ex, "RecordCouponUsageAsync failed for PayOrder {PayOrderId} CouponCode {CouponCode}", payOrderId, request.CouponCode); } } // ── Step 20: Return result ──────────────────────────────────────── // Load the persisted order row so the caller gets all DB-computed fields. string orderJson = await _payOrderDal.GetPayOrder(payOrderId, login).ConfigureAwait(false); // Augment the result object with the CheckoutUrl from the gateway response. // We deserialize, set the field if possible, and re-serialize. // If CheckoutUrl cannot be embedded (e.g., type mismatch) the raw JSON is returned // alongside the gateway checkout URL in a wrapper. return BuildInitiateResult(orderJson, gwResult.CheckoutUrl); } // ── Helpers ─────────────────────────────────────────────────────────── /// /// Merges the CheckoutUrl from the gateway into the persisted order JSON. /// Returns an envelope with both the order data and the checkout URL so the SL /// caller does not need to parse or reconstruct anything. /// private static string BuildInitiateResult(string orderJson, string? checkoutUrl) { // Use a lightweight anonymous wrapper to avoid a full round-trip deserialize. var envelope = new { Order = JsonConvert.DeserializeObject(orderJson), CheckoutUrl = checkoutUrl }; return JsonConvert.SerializeObject(envelope); } } }