From f3687654194da073fb243eefe332cbb7e1fc01b5 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 25 Jun 2026 13:13:21 +0330 Subject: [PATCH] fix(pos): charge the server amount, and don't book unconfirmed card payments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/components/pos2/pos2-screen.tsx | 38 +++++++++++++++++-- .../src/components/ui/alert-dialog.tsx | 6 ++- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/web/dashboard/src/components/pos2/pos2-screen.tsx b/web/dashboard/src/components/pos2/pos2-screen.tsx index 75aca90..2394675 100644 --- a/web/dashboard/src/components/pos2/pos2-screen.tsx +++ b/web/dashboard/src/components/pos2/pos2-screen.tsx @@ -26,6 +26,7 @@ import { submitOrderToApi, orderAmountDue, isLocalOrder } from "@/lib/pos2/submi import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device"; import { printReceipt } from "@/lib/api/print"; import { PosCustomerPicker } from "@/components/pos2/pos-customer-picker"; +import { useConfirm } from "@/components/providers/confirm-provider"; import { Can } from "@/components/auth/can"; import { useHasPermission } from "@/lib/permissions"; 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 // VoidOrder permission; the unsent portion of a line stays freely editable. const canVoid = useHasPermission("VoidOrder"); + const confirm = useConfirm(); // local view state const [view, setView] = useState<"board" | "order">("board"); @@ -288,9 +290,31 @@ export function Pos2Screen() { try { const cardTotal = payments.filter((p) => p.method === "Card").reduce((s, p) => s + p.amount, 0); 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) { - // push the card amount to the configured terminal (no-op/skip if none) - await requestPosPayment(cafeId as string, payBranchId, payTarget.id, cardTotal); + const res = 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`, { payments, @@ -645,7 +669,15 @@ export function Pos2Screen() { {payTarget && ( setPayTarget(null)} onConfirm={confirmPay} diff --git a/web/dashboard/src/components/ui/alert-dialog.tsx b/web/dashboard/src/components/ui/alert-dialog.tsx index d33f031..160051d 100644 --- a/web/dashboard/src/components/ui/alert-dialog.tsx +++ b/web/dashboard/src/components/ui/alert-dialog.tsx @@ -16,7 +16,9 @@ const AlertDialogOverlay = React.forwardRef<