fix(pos): charge the server amount, and don't book unconfirmed card payments
CI/CD / CI · API (dotnet build + test) (push) Successful in 43s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m11s
CI/CD / CI · Admin Web (tsc) (push) Successful in 39s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 2m49s

Two go-live money-correctness bugs in the POS pay flow (deferred TODO #1/#2):

#2 — pay against the server's amount, not a client recompute. The pay sheet
took `orderAmountDue(payTarget) || total`, so any time the server figure was
absent/zero it silently fell back to the POS's own 9% tax recompute. The backend
records whatever amount the client posts (it only uses its own order.Total to
decide closure), so a client/server mismatch books the wrong cash-drawer amount.
Now a real (server) order always charges orderAmountDue(serverOrder); only a
genuinely-local offline order — which has no server figure — uses the client
total.

#1 — don't record a card payment that wasn't confirmed. A connected terminal
that declines already throws POS_DEVICE_* and records nothing. But when no
terminal is wired up the request is "skipped" and the card was booked as paid
with zero proof it cleared. Now, when the card leg isn't machine-confirmed, the
cashier must confirm "card approved on the terminal?" before it's recorded;
cancel records nothing.

Also raise the shared AlertDialog to z-[80] so a confirmation renders above the
POS pay sheet (z-[60]) and its busy overlay (z-[70]); still below toasts.

tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-25 13:13:21 +03:30
parent 197f6f2d38
commit f368765419
2 changed files with 39 additions and 5 deletions
@@ -26,6 +26,7 @@ import { submitOrderToApi, orderAmountDue, isLocalOrder } from "@/lib/pos2/submi
import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device"; import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device";
import { printReceipt } from "@/lib/api/print"; import { printReceipt } from "@/lib/api/print";
import { PosCustomerPicker } from "@/components/pos2/pos-customer-picker"; import { PosCustomerPicker } from "@/components/pos2/pos-customer-picker";
import { useConfirm } from "@/components/providers/confirm-provider";
import { Can } from "@/components/auth/can"; import { Can } from "@/components/auth/can";
import { useHasPermission } from "@/lib/permissions"; import { useHasPermission } from "@/lib/permissions";
import type { Customer, MenuItem, Order, TableBoardItem } from "@/lib/api/types"; import type { Customer, MenuItem, Order, TableBoardItem } from "@/lib/api/types";
@@ -116,6 +117,7 @@ export function Pos2Screen() {
// a cashier must NOT be able to do it (send food, then erase it). Gated on the // a cashier must NOT be able to do it (send food, then erase it). Gated on the
// VoidOrder permission; the unsent portion of a line stays freely editable. // VoidOrder permission; the unsent portion of a line stays freely editable.
const canVoid = useHasPermission("VoidOrder"); const canVoid = useHasPermission("VoidOrder");
const confirm = useConfirm();
// local view state // local view state
const [view, setView] = useState<"board" | "order">("board"); const [view, setView] = useState<"board" | "order">("board");
@@ -288,9 +290,31 @@ export function Pos2Screen() {
try { try {
const cardTotal = payments.filter((p) => p.method === "Card").reduce((s, p) => s + p.amount, 0); const cardTotal = payments.filter((p) => p.method === "Card").reduce((s, p) => s + p.amount, 0);
const payBranchId = payTarget.branchId ?? orderBranchId ?? undefined; const payBranchId = payTarget.branchId ?? orderBranchId ?? undefined;
// Card leg: push the amount to the configured terminal and wait for it. A
// connected terminal that declines throws POS_DEVICE_* (caught below →
// nothing recorded). If no terminal is wired up the request is "skipped",
// so we have NO machine proof the card actually cleared.
let cardConfirmedByTerminal = false;
if (cardTotal > 0 && payBranchId) { if (cardTotal > 0 && payBranchId) {
// push the card amount to the configured terminal (no-op/skip if none) const res = await requestPosPayment(cafeId as string, payBranchId, payTarget.id, cardTotal);
await requestPosPayment(cafeId as string, payBranchId, payTarget.id, cardTotal); cardConfirmedByTerminal = res.sent && !res.skipped;
}
// No integrated-terminal confirmation → make the cashier confirm the card
// was approved before we book it as paid; otherwise a declined card gets
// recorded as revenue the café never received.
if (cardTotal > 0 && !cardConfirmedByTerminal) {
setBusy(false); // hide the processing overlay so the dialog is interactive
const approved = await confirm({
title: "تأیید پرداخت کارتی",
description: `پرداخت کارتی ${fmt(cardTotal)} تومان روی دستگاه پوز با موفقیت انجام شد؟`,
confirmLabel: "بله، پرداخت شد",
cancelLabel: "خیر، لغو",
});
if (!approved) {
notify.error("ثبت پرداخت لغو شد");
return; // finally resets the guards; nothing recorded
}
setBusy(true);
} }
await apiPost(`/api/cafes/${cafeId}/orders/${payTarget.id}/payments`, { await apiPost(`/api/cafes/${cafeId}/orders/${payTarget.id}/payments`, {
payments, payments,
@@ -645,7 +669,15 @@ export function Pos2Screen() {
{payTarget && ( {payTarget && (
<Pos2PaySheet <Pos2PaySheet
tableName={title} tableName={title}
amountDue={orderAmountDue(payTarget) || total} // Charge the server's authoritative outstanding amount. Only a
// genuinely-local (offline) order has no server figure to trust, so
// only then fall back to the client-computed total. Never silently
// swap a real order's server amount for the POS's own 9% recompute.
amountDue={
isLocalOrder(payTarget.id)
? orderAmountDue(payTarget) || total
: orderAmountDue(payTarget)
}
loyaltyPoints={payLoyalty} loyaltyPoints={payLoyalty}
onClose={() => setPayTarget(null)} onClose={() => setPayTarget(null)}
onConfirm={confirmPay} onConfirm={confirmPay}
@@ -16,7 +16,9 @@ const AlertDialogOverlay = React.forwardRef<
<AlertDialogPrimitive.Overlay <AlertDialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/45 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", // z-[80]: a confirmation must sit above app overlays (the POS pay sheet is
// z-[60] and its busy overlay z-[70]); stays below toasts.
"fixed inset-0 z-[80] bg-black/45 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className
)} )}
{...props} {...props}
@@ -33,7 +35,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl border border-border/80 bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", "fixed left-[50%] top-[50%] z-[80] grid w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl border border-border/80 bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className className
)} )}
{...props} {...props}