fix(pos): prevent duplicate orders by checking payment API result

ConfirmPayment() previously ignored PayOrderAsync return value and
always showed success screen, even when payment API returned 500.
This caused users to unknowingly create duplicate orders — one unpaid
(Validated) and one paid (Completed).

Root causes fixed:
- Pass amountTendered to PayOrderWithDetailsAsync (required by
  PayOrderCommandValidator for cash payments)
- Check payment API response before showing success screen
- Show error message with retry option when payment fails
- Add loading state to prevent double-clicks during processing

Affects: CafeDesktop, CafeMobile, CafeTablet, RestaurantDesktop

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-03-29 02:12:57 +07:00
parent 1256ea0c00
commit b81c6ac176
4 changed files with 112 additions and 24 deletions

View File

@@ -209,8 +209,19 @@
</div>
</div>
<div style="padding:16px;border-top:1px solid var(--pos-border-subtle);">
<button class="pos-btn-checkout" @onclick="ConfirmPayment" disabled="@(_receivedAmount < FinalTotal)">
Xác nhận thanh toán
@if (!string.IsNullOrEmpty(_paymentError))
{
<div style="font-size:12px;color:var(--pos-danger);margin-bottom:8px;text-align:center;">@_paymentError</div>
}
<button class="pos-btn-checkout" @onclick="ConfirmPayment" disabled="@(_receivedAmount < FinalTotal || _paymentProcessing)">
@if (_paymentProcessing)
{
<span>Đang xử lý...</span>
}
else
{
<span>Xác nhận thanh toán</span>
}
</button>
</div>
</div>
@@ -246,8 +257,19 @@
}
</div>
<div style="padding:16px;border-top:1px solid var(--pos-border-subtle);">
<button class="pos-btn-checkout" @onclick="ConfirmPayment">
Xác nhận đã thanh toán
@if (!string.IsNullOrEmpty(_paymentError))
{
<div style="font-size:12px;color:var(--pos-danger);margin-bottom:8px;text-align:center;">@_paymentError</div>
}
<button class="pos-btn-checkout" @onclick="ConfirmPayment" disabled="@_paymentProcessing">
@if (_paymentProcessing)
{
<span>Đang xử lý...</span>
}
else
{
<span>Xác nhận đã thanh toán</span>
}
</button>
</div>
</div>
@@ -932,11 +954,13 @@
}
private bool _paymentProcessing;
private string? _paymentError;
private async Task ConfirmPayment()
{
if (_paymentProcessing) return;
_paymentProcessing = true;
_paymentError = null;
StateHasChanged();
_lastOrderTotal = FinalTotal;
@@ -946,16 +970,26 @@
// EN: Call API to mark order as paid (order was already created in StartPayment)
// VI: Gọi API đánh dấu đơn đã thanh toán (đơn đã được tạo ở StartPayment)
bool paymentSuccess = false;
try
{
if (_createdOrderId.HasValue)
{
await DataService.PayOrderAsync(_createdOrderId.Value, ShopId, _selectedMethod);
// EN: Pass amountTendered for cash (required by validator), use FinalTotal for non-cash
// VI: Truyền amountTendered cho tiền mặt (validator yêu cầu), dùng FinalTotal cho phương thức khác
var amount = _selectedMethod == "cash" ? _receivedAmount : FinalTotal;
var result = await DataService.PayOrderWithDetailsAsync(
_createdOrderId.Value, ShopId, _selectedMethod, amount);
paymentSuccess = result?.Success ?? false;
_lastTransactionId = _createdOrderId.Value.ToString()[..8].ToUpper();
}
else
if (!paymentSuccess)
{
_lastTransactionId = $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}";
_paymentError = "Thanh toán thất bại. Vui lòng thử lại.";
_paymentProcessing = false;
StateHasChanged();
return;
}
// EN: Redeem voucher if applied / VI: Sử dụng voucher nếu đã áp dụng
@@ -964,11 +998,12 @@
try { await DataService.RedeemVoucherAsync(_appliedVoucher.VoucherId.Value, _discountAmount); } catch { }
}
}
catch
catch (Exception ex)
{
// EN: Fallback — generate local ID if API fails
// VI: Fallback — tạo ID local nếu API lỗi
_lastTransactionId = $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}";
_paymentError = $"Lỗi thanh toán: {ex.Message}. Vui lòng thử lại.";
_paymentProcessing = false;
StateHasChanged();
return;
}
// EN: Save to session history (in-memory for this POS session)

View File

@@ -518,27 +518,37 @@
}
private bool _paymentProcessing;
private string? _paymentError;
private async Task ConfirmPayment()
{
if (_paymentProcessing) return;
_paymentProcessing = true;
_paymentError = null;
StateHasChanged();
_lastOrderTotal = FinalTotal;
_lastPaymentMethod = _selectedMethod;
_lastReceiptItems = _cartItems.Select(i => (i.Name, i.Qty, i.Price)).ToList();
bool paymentSuccess = false;
try
{
if (_createdOrderId.HasValue)
{
await DataService.PayOrderAsync(_createdOrderId.Value, ShopId);
var amount = _selectedMethod == "cash" ? _receivedAmount : FinalTotal;
var result = await DataService.PayOrderWithDetailsAsync(
_createdOrderId.Value, ShopId, _selectedMethod, amount);
paymentSuccess = result?.Success ?? false;
_lastTransactionId = _createdOrderId.Value.ToString()[..8].ToUpper();
}
else
if (!paymentSuccess)
{
_lastTransactionId = $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}";
_paymentError = "Thanh toán thất bại. Vui lòng thử lại.";
_paymentProcessing = false;
StateHasChanged();
return;
}
if (_appliedVoucher?.VoucherId != null && _discountAmount > 0)
@@ -546,9 +556,12 @@
try { await DataService.RedeemVoucherAsync(_appliedVoucher.VoucherId.Value, _discountAmount); } catch { }
}
}
catch
catch (Exception ex)
{
_lastTransactionId = $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}";
_paymentError = $"Lỗi thanh toán: {ex.Message}. Vui lòng thử lại.";
_paymentProcessing = false;
StateHasChanged();
return;
}
_paymentProcessing = false;

View File

@@ -495,27 +495,37 @@
}
private bool _paymentProcessing;
private string? _paymentError;
private async Task ConfirmPayment()
{
if (_paymentProcessing) return;
_paymentProcessing = true;
_paymentError = null;
StateHasChanged();
_lastOrderTotal = FinalTotal;
_lastPaymentMethod = _selectedMethod;
_lastReceiptItems = _cartItems.Select(i => (i.Name, i.Qty, i.Price)).ToList();
bool paymentSuccess = false;
try
{
if (_createdOrderId.HasValue)
{
await DataService.PayOrderAsync(_createdOrderId.Value, ShopId);
var amount = _selectedMethod == "cash" ? _receivedAmount : FinalTotal;
var result = await DataService.PayOrderWithDetailsAsync(
_createdOrderId.Value, ShopId, _selectedMethod, amount);
paymentSuccess = result?.Success ?? false;
_lastTransactionId = _createdOrderId.Value.ToString()[..8].ToUpper();
}
else
if (!paymentSuccess)
{
_lastTransactionId = $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}";
_paymentError = "Thanh toán thất bại. Vui lòng thử lại.";
_paymentProcessing = false;
StateHasChanged();
return;
}
if (_appliedVoucher?.VoucherId != null && _discountAmount > 0)
@@ -523,9 +533,12 @@
try { await DataService.RedeemVoucherAsync(_appliedVoucher.VoucherId.Value, _discountAmount); } catch { }
}
}
catch
catch (Exception ex)
{
_lastTransactionId = $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}";
_paymentError = $"Lỗi thanh toán: {ex.Message}. Vui lòng thử lại.";
_paymentProcessing = false;
StateHasChanged();
return;
}
_paymentProcessing = false;

View File

@@ -674,10 +674,13 @@
_receivedAmount = val;
}
private string? _paymentError;
private async Task ConfirmPayment()
{
if (_paymentProcessing || SelectedTable == null) return;
_paymentProcessing = true;
_paymentError = null;
StateHasChanged();
_lastOrderTotal = GrandTotal;
@@ -686,10 +689,34 @@
_lastTransactionId = $"POS-{DateTime.Now:yyyyMMdd}-{DateTime.Now:HHmmss}";
// Pay all orders for this table in the backend
if (_tableOrderIds.TryGetValue(SelectedTable.Id, out var orderIds))
bool allPaid = true;
try
{
foreach (var orderId in orderIds)
await DataService.PayOrderAsync(orderId, ShopId);
if (_tableOrderIds.TryGetValue(SelectedTable.Id, out var orderIds))
{
var amount = _selectedMethod == "cash" ? _receivedAmount : GrandTotal;
foreach (var orderId in orderIds)
{
var result = await DataService.PayOrderWithDetailsAsync(
orderId, ShopId, _selectedMethod, amount);
if (result?.Success != true) allPaid = false;
}
}
if (!allPaid)
{
_paymentError = "Thanh toán thất bại. Vui lòng thử lại.";
_paymentProcessing = false;
StateHasChanged();
return;
}
}
catch (Exception ex)
{
_paymentError = $"Lỗi thanh toán: {ex.Message}. Vui lòng thử lại.";
_paymentProcessing = false;
StateHasChanged();
return;
}
_paymentProcessing = false;