Ver Fonte

1000 vé

student há 3 semanas atrás
pai
commit
0fc2dd4a36

+ 58 - 18
website/Areas/Millions/Controllers/HomeController.cs

@@ -26,6 +26,8 @@ namespace LotteryWebApp.Areas.Millions.Controllers
         private const string FreeTicketAutoCheckSessionKey = "Millions.FreeTicket.AutoChecked";
         private const string FreeTicketPromotionCodeSessionKey = "Millions.FreeTicket.PromotionCode";
         private const string FreeTicketTransactionSessionKey = "Millions.FreeTicket.TransactionId";
+        private const string PendingTicketTransactionSessionKey = "Millions.PendingTicket.TransactionId";
+        private const string OtpVerifiedTransactionSessionKey = "Millions.OTP.VerifiedTransactionId";
 
         IConfiguration configuration;
         private readonly IWebHostEnvironment webHostEnvironment;
@@ -1068,17 +1070,23 @@ namespace LotteryWebApp.Areas.Millions.Controllers
 
                 // DEBUG: Log request data
                 var ticketDebug = request.ticket != null ? string.Join("; ", request.ticket.Select(t => $"code={t.code}, money={t.money}")) : "NULL";
-                log.Info($"[ConfirmTicketData] gameId={request.gameId}, msisdn={request.msisdn}, tickets=[{ticketDebug}]");
+                log.Info($"[BuyTicketV2.Prepare] gameId={request.gameId}, msisdn={request.msisdn}, tickets=[{ticketDebug}]");
 
                 ConfirmTicketDataResponse response = api.BuyTicketV2Api(configuration, request);
 
+                if (response.responseCode == Code.SUCCESS && !string.IsNullOrWhiteSpace(response.transId))
+                {
+                    HttpContext.Session.SetString(PendingTicketTransactionSessionKey, response.transId);
+                    HttpContext.Session.Remove(OtpVerifiedTransactionSessionKey);
+                }
+
                 if (isValidPromotionCode && response.responseCode == Code.SUCCESS && !string.IsNullOrWhiteSpace(response.transId))
                 {
                     HttpContext.Session.SetString(FreeTicketTransactionSessionKey, response.transId);
                 }
                 
                 // DEBUG: Log response
-                log.Info($"[ConfirmTicketData] Response: code={response.responseCode}, msg={response.responseMessage}, transId={response.transId}");
+                log.Info($"[BuyTicketV2.Prepare] Response: code={response.responseCode}, msg={response.responseMessage}, transId={response.transId}");
                 
                 return Json(response);
             }
@@ -1101,7 +1109,23 @@ namespace LotteryWebApp.Areas.Millions.Controllers
                 request.token = token;
                 request.msisdn = msisdn;
 
+                var transactionId = request.transIdByTicket;
+                var pendingTransactionId = HttpContext.Session.GetString(PendingTicketTransactionSessionKey);
+                if (string.IsNullOrWhiteSpace(transactionId)
+                    || !string.Equals(transactionId, pendingTransactionId, StringComparison.Ordinal))
+                {
+                    return Json(new { responseCode = "4", responseMessage = GetLangFromCode("4") });
+                }
+
+                // This value is only used by the website to bind OTP verification
+                // to the prepared transaction; it must not be sent to the OTP API.
+                request.transIdByTicket = null;
+
                 ConfirmOTPResponse response = api.ConfirmOTPApi(configuration, request);
+                if (response.responseCode == Code.SUCCESS)
+                {
+                    HttpContext.Session.SetString(OtpVerifiedTransactionSessionKey, transactionId);
+                }
                 return Json(response);
             }
             catch (Exception ex)
@@ -1129,6 +1153,10 @@ namespace LotteryWebApp.Areas.Millions.Controllers
                 };
 
                 SendOTPResponse response = api.SendOTPApi(configuration, request);
+                if (response.responseCode == Code.SUCCESS)
+                {
+                    HttpContext.Session.Remove(OtpVerifiedTransactionSessionKey);
+                }
                 return Json(response);
             }
             catch (Exception ex)
@@ -1152,22 +1180,18 @@ namespace LotteryWebApp.Areas.Millions.Controllers
                 var isFreeTicketTransaction = !string.IsNullOrWhiteSpace(request.transIdByTicket)
                     && string.Equals(request.transIdByTicket, freeTicketTransactionId, StringComparison.Ordinal);
 
-                // The main-account flow keeps the existing OTP verification.
-                if (!isWalletPayment && !isFreeTicketTransaction)
+                // Main-account and promotion purchases must have completed the
+                // separate ConfirmOTP request for this exact prepared transaction.
+                if (!isWalletPayment)
                 {
-                    ConfirmOTPRequest otpRequest = new ConfirmOTPRequest
-                    {
-                        otp = request.paymentCode,
-                        msisdn = msisdn,
-                        token = token,
-                        language = CultureInfo.CurrentCulture.Name.StartsWith("en") ? "0" : "1",
-                        channel = configuration.GetSection("channel").Value
-                    };
-                    ConfirmOTPResponse otpResponse = api.ConfirmOTPApi(configuration, otpRequest);
-                    if (otpResponse.responseCode != Code.SUCCESS)
+                    var otpVerifiedTransactionId = HttpContext.Session.GetString(OtpVerifiedTransactionSessionKey);
+                    if (string.IsNullOrWhiteSpace(request.transIdByTicket)
+                        || !string.Equals(request.transIdByTicket, otpVerifiedTransactionId, StringComparison.Ordinal))
                     {
-                        return Json(new { responseCode = otpResponse.responseCode, responseMessage = otpResponse.responseMessage });
+                        return Json(new { responseCode = "31", responseMessage = GetLangFromCode("31") });
                     }
+
+                    HttpContext.Session.Remove(OtpVerifiedTransactionSessionKey);
                 }
 
                 // Step 2: Proceed to Confirm buying
@@ -1179,9 +1203,25 @@ namespace LotteryWebApp.Areas.Millions.Controllers
 
                 request.gameId = Constants.Millions_CODE;
 
-                ConfirmBuyingTicketResponse response = isWalletPayment
-                    ? api.ConfirmBuyingTicketV2Api(configuration, request, Constants.WALLET_PAYMENT_CHANNEL, request.walletPin)
-                    : api.ConfirmBuyingTicketApi(configuration, request);
+                ConfirmBuyingTicketResponse response;
+                if (isWalletPayment)
+                {
+                    response = api.ConfirmBuyingTicketV2Api(
+                        configuration,
+                        request,
+                        Constants.WALLET_PAYMENT_CHANNEL,
+                        request.walletPin);
+                }
+                else if (isFreeTicketTransaction)
+                {
+                    // Promotion transactions are created by BuyTicketV2 and must
+                    // be completed by the matching V2 confirmation endpoint.
+                    response = api.ConfirmBuyingTicketV2Api(configuration, request);
+                }
+                else
+                {
+                    response = api.ConfirmBuyingTicketApi(configuration, request);
+                }
                 if (response.responseCode == Code.SUCCESS)
                 {
                     if (isFreeTicketTransaction)

+ 83 - 17
website/Areas/Millions/Views/Home/BuyTicket.cshtml

@@ -208,7 +208,7 @@
                         <span class="text-[13px] font-black" style="color: @themeColor">HTG</span>
                     </div>
                 </div>
-                <button id="bsPaymentBtn" onclick="bsShowPayment()" class="text-white text-[20px] font-black px-10 py-3.5 rounded-xl shadow-lg active:scale-95 transition-all" style="background: @themeColor;">
+                <button type="button" id="bsPaymentBtn" onpointerdown="armPaymentClick(event)" onkeydown="armPaymentKey(event)" onclick="bsShowPayment(event)" class="text-white text-[20px] font-black px-10 py-3.5 rounded-xl shadow-lg active:scale-95 transition-all" style="background: @themeColor;">
                     @Lang.millions_payment
                 </button>
             </div>
@@ -318,7 +318,7 @@ else
                     <span class="text-[13px] font-black text-[#0062FF]">HTG</span>
                 </div>
             </div>
-            <button onclick="preparePayment()" class="bg-[#0062FF] text-white text-[20px] font-black px-10 py-3.5 rounded-xl shadow-[0px_6px_15px_rgba(238,0,51,0.25)] active:scale-95 transition-all">
+            <button type="button" onpointerdown="armPaymentClick(event)" onkeydown="armPaymentKey(event)" onclick="preparePayment(event)" class="bg-[#0062FF] text-white text-[20px] font-black px-10 py-3.5 rounded-xl shadow-[0px_6px_15px_rgba(238,0,51,0.25)] active:scale-95 transition-all">
                 @Lang.millions_payment
             </button>
         </div>
@@ -1017,8 +1017,29 @@ else
         // --- Payment Preparation & Order Summary Logic ---
         let pendingTickets = [];
         let currentChannelPayment = '@Constants.BASIC_WALLET_TICKET';
+        let isPreparingCheckout = false;
+        let isSendingOtp = false;
+        let isFinalizingPurchase = false;
+        let paymentClickArmedAt = 0;
+
+        function armPaymentClick(event) {
+            if (event && event.isTrusted === true) paymentClickArmedAt = Date.now();
+        }
+
+        function armPaymentKey(event) {
+            if (event && event.isTrusted === true && (event.key === "Enter" || event.key === " ")) {
+                paymentClickArmedAt = Date.now();
+            }
+        }
 
         function preparePayment(event) {
+            // Never prepare or confirm a ticket during page initialization.
+            // A trusted pointer/key action on this button must immediately precede the click.
+            const hasFreshPaymentIntent = paymentClickArmedAt > 0
+                && Date.now() - paymentClickArmedAt <= 1500;
+            paymentClickArmedAt = 0;
+            if (!event || event.type !== "click" || event.isTrusted !== true || !hasFreshPaymentIntent) return;
+
             const isBSMode = @(Model.termType == Constants.PIC10_BIGSMALL_CODE || Model.termType == Constants.PIC10_ODDEVEN_CODE ? "true" : "false");
             const tickets = [];
 
@@ -1074,6 +1095,9 @@ else
         }
 
         function selectPaymentChannel(channelPayment) {
+            if (isPreparingCheckout) return;
+            isPreparingCheckout = true;
+
             currentChannelPayment = channelPayment;
             hidePaymentChannelModal();
 
@@ -1100,6 +1124,9 @@ else
                 error: function(err) {
 
                     showNotification("Network error occurred.", "warning");
+                },
+                complete: function() {
+                    isPreparingCheckout = false;
                 }
             });
         }
@@ -1173,17 +1200,14 @@ else
         }
 
         function confirmCheckout(btn) {
-            if (hasFreeTicketPromotion) {
-                hideOrderSummary();
-                finalizePurchase(btn, true);
-                return;
-            }
+            if (isSendingOtp) return;
 
             if (currentChannelPayment === '@Constants.NATCASH_WALLET_TICKET') {
                 showOtpModal(true);
                 return;
             }
 
+            isSendingOtp = true;
             const originalText = $(btn).html();
             $(btn).prop('disabled', true).html('<i class="fa-solid fa-spinner fa-spin mr-2"></i>Processing...');
 
@@ -1203,6 +1227,9 @@ else
                 error: function() {
                     $(btn).prop('disabled', false).html(originalText);
                     showNotification("Network error occurred.", "warning");
+                },
+                complete: function() {
+                    isSendingOtp = false;
                 }
             });
         }
@@ -1378,33 +1405,69 @@ else
             $("#receiptSuccessModal").removeClass("hidden").addClass("flex");
         }
 
-        function finalizePurchase(btn, skipOtpValidation) {
-            const isFreeTicketPurchase = hasFreeTicketPromotion && skipOtpValidation === true;
+        function finalizePurchase(btn) {
+            if (isFinalizingPurchase) return;
+
             const isWalletPayment = currentChannelPayment === '@Constants.NATCASH_WALLET_TICKET';
-            let otpCode = isFreeTicketPurchase
-                ? ""
-                : (isWalletPayment ? $("#walletPinInput").val().trim() : "");
-            if (!isFreeTicketPurchase && !isWalletPayment) {
+            let otpCode = isWalletPayment ? $("#walletPinInput").val().trim() : "";
+            if (!isWalletPayment) {
                 $("#otpInputs input").each(function() { otpCode += $(this).val(); });
             }
             
-            if (!isFreeTicketPurchase && ((isWalletPayment && otpCode.length === 0) || (!isWalletPayment && otpCode.length < 6))) {
+            if ((isWalletPayment && otpCode.length === 0) || (!isWalletPayment && otpCode.length < 6)) {
                 showNotification(isWalletPayment ? '@Html.Raw(Lang.payment_wallet_pin_instruction.Replace("'", "\\'"))' : "Please enter full 6-digit OTP", "warning");
                 return;
             }
 
+            isFinalizingPurchase = true;
             const originalText = $(btn).html();
             $(btn).prop('disabled', true).html('<i class="fa-solid fa-spinner fa-spin mr-2"></i> ...');
 
+            if (isWalletPayment) {
+                submitFinalPurchase(btn, originalText, true, otpCode);
+                return;
+            }
+
+            // Confirm the OTP first. The final purchase API is called only after
+            // this request succeeds for the current prepared transaction.
+            $.ajax({
+                url: subDomain + '@Url.Action("ConfirmOTP", "Home")',
+                type: 'POST',
+                contentType: 'application/json',
+                data: JSON.stringify({
+                    otp: otpCode,
+                    transIdByTicket: currentTransId
+                }),
+                success: function(otpResult) {
+                    if (otpResult.responseCode === "0" || otpResult.responseCode === "0000") {
+                        submitFinalPurchase(btn, originalText, false, null);
+                        return;
+                    }
+
+                    $(btn).prop('disabled', false).html(originalText);
+                    isFinalizingPurchase = false;
+                    $("#otpError").text(otpResult.responseMessage || "Invalid OTP").removeClass("hidden");
+                    $("#otpInputs input").val("");
+                    $("#otp1").focus();
+                },
+                error: function() {
+                    $(btn).prop('disabled', false).html(originalText);
+                    isFinalizingPurchase = false;
+                    showNotification("Network error occurred during OTP verification.", "warning");
+                }
+            });
+        }
+
+        function submitFinalPurchase(btn, originalText, isWalletPayment, walletPin) {
             const finalData = {
                 transIdByTicket: currentTransId,
                 gameId: isFreeTicketMode ? "@Constants.Millions_CODE" : "@Model.termType",
-                paymentCode: isFreeTicketPurchase || currentChannelPayment === '@Constants.NATCASH_WALLET_TICKET' ? null : otpCode,
-                walletPin: !isFreeTicketPurchase && currentChannelPayment === '@Constants.NATCASH_WALLET_TICKET' ? otpCode : null,
+                paymentCode: null,
+                walletPin: isWalletPayment ? walletPin : null,
                 channelPayment: currentChannelPayment
             };
 
-            // Free-ticket transactions skip OTP; paid transactions keep the existing verification flow.
+            // This final API is unreachable until ConfirmOTP succeeds (or a wallet PIN is supplied).
             $.ajax({
                 url: subDomain + '@Url.Action("ConfirmBuyingTicketV2", "Home")',
                 type: 'POST',
@@ -1434,6 +1497,9 @@ else
                 error: function() {
                     $(btn).prop('disabled', false).html(originalText);
                     showNotification("Network error occurred during payment completion.", "warning");
+                },
+                complete: function() {
+                    isFinalizingPurchase = false;
                 }
             });
         }

+ 2 - 0
website/Service/ApiModels.cs

@@ -309,6 +309,8 @@ namespace LotteryWebApp.Service
     public class ConfirmOTPRequest : Posting
     {
         public string otp { get; set; }
+        [Newtonsoft.Json.JsonProperty(NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)]
+        public string transIdByTicket { get; set; }
         public string msisdn { get; set; }
         public string language { get; set; }
         public string channel { get; set; }