diff --git a/docs/architecture/authentication-and-authorization.md b/docs/architecture/authentication-and-authorization.md index 812936781..052292c4e 100644 --- a/docs/architecture/authentication-and-authorization.md +++ b/docs/architecture/authentication-and-authorization.md @@ -35,8 +35,7 @@ list. `requireRole` (in `main/middleware/security.ts`) still exists, but a stati (`tests/authorization-static-audit.test.ts`) fails the build if a new call site under `main/routes/` uses it; the few remaining direct role checks are reviewed context policy, covered in their own sections below (KDS station/category scope, refund approver identity, the -Staff Approval PIN holder for order-status, kitchen-delivery, charge-waiver, discount, and item cancel/void overrides, and the -last-active-owner/staff-target rule). +Staff Approval PIN holder, and the last-active-owner/staff-target rule). **Resolution.** For every permission id, `resolveEffectivePermissions(userId)` evaluates, in order: the protected-owner rule (for `authorization.manage` and `staff.privileged.manage` only, @@ -137,7 +136,10 @@ permission check inside the `withTxn` callback, after the transaction opens, usi `hasPermission(actorId, id)` against state read in the same transaction. `PATCH /api/orders/:id/status`, `PATCH /api/orders/:orderId/items/:itemId/cancel`, the matching `.../restore` endpoint in `main/routes/orders.ts`, and the bill payment kitchen-delivery override -and charge waiver override in `main/routes/bills.ts` do this, and their override-PIN paths additionally require the presenting +and charge waiver override in `main/routes/bills.ts` do this, and their override-PIN paths +(along with paid-bill payment-method correction on `PATCH /api/bills/:id/payments/:index/method`, +which also requires the authenticated caller itself to satisfy `hasRole(role, ROLE_ACCESS.ownerManager)`) +additionally require the presenting user to satisfy `hasRole(role, ROLE_ACCESS.ownerManager)` - a deliberate context-policy role check, not a permission, because the Staff Approval PIN belongs to a person acting in an owner/manager capacity regardless of what their individual permission grants @@ -216,10 +218,10 @@ terminals, while approval authority stays restricted to active `owner` and `mana and charge waivers), cached override PINs, and open privileged modals are cleared and stale asynchronous responses from the previous session are ignored. 4. **Approval boundary:** `GET /api/staff/approvers` and every Staff Approval PIN check (refunds, - item voids, order/item/bill discounts, order-status overrides, kitchen-delivery overrides, and - charge waivers) enforce active `owner` or `manager` role eligibility both outside and inside - write transactions. A valid `cashier`, `server`, or `chef` PIN is rejected with `403` on every - approval path. + item voids, order/item/bill discounts, order-status overrides, kitchen-delivery overrides, + charge waivers, and paid-bill payment-method corrections) enforce active `owner` or `manager` + role eligibility both outside and inside write transactions. A valid `cashier`, `server`, or + `chef` PIN is rejected with `403` on every approval path. ## Password policy diff --git a/docs/architecture/business-time.md b/docs/architecture/business-time.md index e9f68a0c7..90cfd8e8d 100644 --- a/docs/architecture/business-time.md +++ b/docs/architecture/business-time.md @@ -89,8 +89,11 @@ function tenantTimezone(): string { - [`main/routes/reports.ts`](../../main/routes/reports.ts), `tenantTimezone`, guards `bucketByLocalHourAndWeekday` and `dayBoundsInTimezone` in reporting. -- [`main/routes/cash-closures.ts`](../../main/routes/cash-closures.ts) exports `tenantTimezone` so - that `cash-sessions.ts` resolves the same window rather than a second, divergent one. +- [`main/services/cash-drawer-movements.ts`](../../main/services/cash-drawer-movements.ts) defines + `tenantTimezone` (re-exported by [`main/routes/cash-closures.ts`](../../main/routes/cash-closures.ts) + for `cash-sessions.ts` and imported by [`main/routes/bills.ts`](../../main/routes/bills.ts)) so that + cash closures, shift sessions, and bill business-day checks resolve the same window rather than a + second, divergent one. - [`main/services/refund.ts`](../../main/services/refund.ts) resolves inline, because a wrong business-day end there can wrongly accept or reject a late refund. diff --git a/docs/architecture/order-lifecycle.md b/docs/architecture/order-lifecycle.md index ac55c06a7..c8af957bc 100644 --- a/docs/architecture/order-lifecycle.md +++ b/docs/architecture/order-lifecycle.md @@ -230,7 +230,9 @@ payment with `409`. A zero-balance payment retry on an already-settled bill retu idempotently without rejecting. The response carries the updated bill plus the wallet and loyalty effects; `bill.payment_details` is the settlement ledger, one entry per line with the applied amount, the requested amount, cash tendered -and change, the `cash_session_id`, and the timestamp. +and change, the `cash_session_id`, and the timestamp. While a `paid` bill's cash shift and business +day remain open, the tender method on a recorded non-wallet line can be corrected without changing +its amount via `PATCH /api/bills/:id/payments/:index/method` (see [the Bills API reference](../reference/api.md#bills)). ## Settlement and completion diff --git a/docs/reference/api.md b/docs/reference/api.md index eedfcc32d..97b7c3e25 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -405,6 +405,7 @@ Bill generation, settlement, and completion are described end to end in | `POST` | `/:id/split-check` | `ROLE_ACCESS.ownerManagerCashier` | body: `checks` | Body `checks` must hold between 2 and 20 entries and the resulting split group is capped at 20 checks. An unpaid untouched dine-in check is divided by allocating every active order item quantity; a split-group child may be divided again while it stays unpaid with no payment recorded, keeping its bill id, bill number and group, while paid siblings keep their own rows and payments. `400` when a request does not allocate exactly the source's own quantities. `403` when `split_checks_enabled` is off. `409` when the source has a recorded payment, a legacy already-split bill is re-split, the source has no divisible allocation, or the resulting split group would exceed 20 checks. | | `POST` | `/:id/payment` | `ROLE_ACCESS.ownerManagerCashier` | path: `id`; body: payment line object (accepts optional `override_pin`, `approver_id`, `manager_id`, `user_id`), `customer_id`; header: `Idempotency-Key` | See [Order lifecycle](../architecture/order-lifecycle.md#payments) for input, replay, gate, and settlement behavior. | | `POST` | `/:id/payments` | `ROLE_ACCESS.ownerManagerCashier` | path: `id`; body: `payments`, `customer_id`, optional `override_pin`, `approver_id`, `manager_id`, `user_id`; header: `Idempotency-Key` | See [Order lifecycle](../architecture/order-lifecycle.md#payments) for input, replay, gate, and settlement behavior. | +| `PATCH` | `/:id/payments/:index/method` | `ROLE_ACCESS.ownerManager` + `payments.take` + active `owner` or `manager` Staff Approval PIN | path: `id`, `index`; body: `method`, `override_pin`, optional `payment_method_id`, `transaction_id`, `reason`, `approver_id`, `manager_id`, `user_id`; header: `Idempotency-Key` | `{ bill, audit }` (with `idempotentReplay: true` on `Idempotency-Key` or natural replay). Corrects the tender method on payment line `:index` of a `paid` bill without changing the amount (`400` `amount_immutable` if a differing `amount` is supplied, `400` `wallet_method_immutable` if changing to `wallet`, `400` `card_transaction_id_required` when required for `card`, `400` `payment_method_unavailable` for unknown methods). Caller must be an authenticated active `owner` or `manager` (`403` `insufficient_role_permissions` for `cashier`, `server`, or `chef` callers) and must provide an active `owner` or `manager` Staff Approval PIN (`403` when missing, invalid, or belonging to a `cashier`, `server`, or `chef`; `429` when rate-limited). Returns `404` when the bill or payment line is not found, and `409` when the bill is not `paid` (`bill_not_paid`), the existing line is `wallet` (`wallet_method_immutable`), the payment's cash shift is closed, missing/unverifiable, or was recorded without an open shift (`shift_closed`), the store business day is closed (`day_closed`), the bill, line, or a split sibling has been refunded (`bill_refunded`, `payment_line_refunded`, `split_sibling_refunded`) or settled in a closed or different shift (`split_sibling_shift_closed`), the target custom payment method is inactive (`payment_method_inactive`), `transaction_id` is already used (`transaction_reference_used`, `transaction_reference_reused`), or `Idempotency-Key` conflicts (`idempotency_key_conflict`). Audited in `order_audit_log` as `bill.payment_method.corrected`. | | `POST` | `/:id/applyDiscount` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `type`, `value`, `reason`, `override_pin`, `approver_id`, `manager_id`, `user_id` | `400` when the bill is paid (`paid`). `409` when the order is completed or cancelled (`order_not_discountable`), the bill is split (`split_check`) or refunded (`refunded`), or the discount would reduce the bill total below collected payments (`below_collected_funds`). Auto-settles the bill to `paid`, completes the order, and frees the table when remaining balance reaches zero on a partially paid bill (gated by kitchen delivery; see [Settlement and completion](../architecture/order-lifecycle.md#settlement-and-completion)). Audited as `order_discount_applied`. | | `PATCH` | `/:id/charges` | `bills.discount.apply` | path: `id`; body: `charge_id`, `waived` or `applied`, optional `override_pin`, `approver_id`, `manager_id`, `user_id` | `{ bill }` with updated charge breakdown and totals. Only for unpaid, unsplit bills with no payments; the charge must be active and apply to the order type. Waiving a charge when the caller is outside `ROLE_ACCESS.ownerManager` (or when `override_pin` is supplied) requires an active `owner` or `manager` Staff Approval PIN holding `bills.discount.apply` (`403` with `requiresApproval: true` when missing, `403` on invalid PIN, `429` when rate-limited, `400` on conflicting approver ids). `409` when the order is cancelled. | | `POST` | `/:id/markPrinted` | `bills.print` | path: `id` | Stamps `bills.printed_at`. | diff --git a/docs/reference/roles-and-permissions.md b/docs/reference/roles-and-permissions.md index c9fd3264f..59d1d4545 100644 --- a/docs/reference/roles-and-permissions.md +++ b/docs/reference/roles-and-permissions.md @@ -171,6 +171,15 @@ manager PIN (with `payments.take` when overriding unsettled payment), even when is already an owner or manager. Full safeguard, restock, and audit rules are in [the Orders API reference](api.md#orders). +**Paid bill payment method correction.** Only an active `owner` or `manager` holding `payments.take` +can correct the tender method on a recorded payment line of a `paid` bill +(`PATCH /api/bills/:id/payments/:index/method`) while its cash shift and store business day remain +open (`Yes (with Staff Approval PIN)` for Owner and Manager; `No` for Cashier, Server, and Chef). +Every correction requires an active `owner` or `manager` Staff Approval PIN (`override_pin`, +`approver_id`); a `cashier`, `server`, or `chef` caller or PIN never authorizes this action (`403`). +Full shift, refund, split-sibling, amount-immutability, and audit rules are in +[the Bills API reference](api.md#bills). + **Owner visibility.** The permission editor itself is rendered only for an authenticated user holding `authorization.manage` — an active owner, always. The API enforces this independently; hiding a control in the interface is not a security boundary. diff --git a/frontend/src/app/(dashboard)/orders/page.tsx b/frontend/src/app/(dashboard)/orders/page.tsx index bc66873aa..9de8061b7 100644 --- a/frontend/src/app/(dashboard)/orders/page.tsx +++ b/frontend/src/app/(dashboard)/orders/page.tsx @@ -59,6 +59,7 @@ import { classifyOrderRequestFailure } from '@/lib/order-attempt'; import { OrderCard } from '@/components/orders/OrderCard'; import { OrdersMasterList } from '@/components/orders/OrdersMasterList'; import { OrderDetailPanel } from '@/components/orders/OrderDetailPanel'; +import CorrectPaymentMethodModal from '@/components/orders/CorrectPaymentMethodModal'; import EditOrderItemModal from '@/components/orders/EditOrderItemModal'; import { PriceOverrideModal } from '@/components/orders/PriceOverrideModal'; import { useOrdersLayoutPreference } from '@/hooks/useOrdersLayout'; @@ -189,6 +190,10 @@ export default function OrdersPage() { const [tabFilter, setTabFilter] = useState('active'); const [paymentBill, setPaymentBill] = useState(null); const [refundModal, setRefundModal] = useState<{ order: Order; bills: Bill[] } | null>(null); + const [correctPaymentModal, setCorrectPaymentModal] = useState<{ + order: Order; + bills: Bill[]; + } | null>(null); const [tables, setTables] = useState([]); // null until /settings/kds_enabled resolves; the WebSocket stays closed // until the feature is confirmed on, because a disabled KDS refuses the @@ -1876,6 +1881,12 @@ export default function OrdersPage() { onPayBill={handlePayBill} onAddItems={openAddItemsModal} onRefund={(ord, bills) => setRefundModal({ order: ord, bills })} + onCorrectPaymentMethod={ + (currentTenant?.role === 'owner' || currentTenant?.role === 'manager') && + tenantCan(currentTenant, 'payments.take') + ? (ord, bills) => setCorrectPaymentModal({ order: ord, bills }) + : undefined + } onConvertToTakeaway={handleConvertToTakeaway} onCancelOrder={(ord) => setCancelModal({ @@ -2048,6 +2059,19 @@ export default function OrdersPage() { /> )} + {/* Correct Payment Method Modal */} + {correctPaymentModal && ( + setCorrectPaymentModal(null)} + onCorrected={() => { + setCorrectPaymentModal(null); + fetchOrders(undefined, { refreshLoadedPages: true }); + }} + /> + )} + {/* Print Confirmation Modal */} {confirmPrintBillId !== null && (
diff --git a/frontend/src/components/orders/CorrectPaymentMethodModal.tsx b/frontend/src/components/orders/CorrectPaymentMethodModal.tsx new file mode 100644 index 000000000..12e954de7 --- /dev/null +++ b/frontend/src/components/orders/CorrectPaymentMethodModal.tsx @@ -0,0 +1,385 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { ApproverSelect } from '@/components/ApproverSelect'; +import { Ltr } from '@/components/layout/Ltr'; +import { useManagerApprovers } from '@/hooks/useManagerApprovers'; +import { useFormatCurrency } from '@/hooks/useFormatCurrency'; +import api from '@/lib/api'; +import { createPaymentIdempotencyKey } from '@/lib/payment-idempotency'; +import { PAYMENT_METHODS, type CustomPaymentMethod } from '@/lib/payment-methods'; +import { normalizeBillPaymentDetails } from '@/components/orders/OrderDetailPanel'; +import type { Bill, Order } from '@/lib/types'; +import toast from 'react-hot-toast'; +import { useTranslations } from 'use-intl'; + +const BUILT_IN_PAYMENT_KEYS = { + cash: 'methodCash', + card: 'methodCard', +} as const; + +function extractErrorMessage(error: unknown): string | null { + const data = (error as { response?: { data?: { error?: unknown } } } | undefined)?.response?.data; + return typeof data?.error === 'string' && data.error.trim() ? data.error.trim() : null; +} + +function firstCorrectableLineIndex(lines: Array<{ method?: string }>): number { + const idx = lines.findIndex((line) => String(line?.method || '').toLowerCase() !== 'wallet'); + return idx >= 0 ? idx : 0; +} + +interface Props { + order: Order; + bills: Bill[]; + onClose: () => void; + onCorrected: (updatedBill?: Bill) => void; +} + +export default function CorrectPaymentMethodModal({ order, bills, onClose, onCorrected }: Props) { + const tOrders = useTranslations('orders'); + const tPos = useTranslations('pos'); + const tCommon = useTranslations('common'); + const fmt = useFormatCurrency(); + + const [selectedBillId, setSelectedBillId] = useState(() => bills[0]?.id ?? 0); + const effectiveBill = bills.find((b) => b.id === selectedBillId) ?? bills[0] ?? null; + + const paymentLines = normalizeBillPaymentDetails(effectiveBill?.payment_details); + const initialLineIndex = firstCorrectableLineIndex(paymentLines); + const initialLine = paymentLines[initialLineIndex] ?? paymentLines[0] ?? null; + const [selectedIndex, setSelectedIndex] = useState(initialLineIndex); + + const [customMethods, setCustomMethods] = useState([]); + const [cardTransactionIdRequired, setCardTransactionIdRequired] = useState(false); + const [methodSelection, setMethodSelection] = useState(() => { + const firstMethod = String(initialLine?.method || '').toLowerCase(); + return firstMethod === 'cash' ? 'card' : 'cash'; + }); + const [transactionId, setTransactionId] = useState(() => + initialLine?.transaction_id ? String(initialLine.transaction_id) : '', + ); + const [reason, setReason] = useState(''); + const [overridePin, setOverridePin] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [idempotencyKey, setIdempotencyKey] = useState(() => createPaymentIdempotencyKey()); + + const managerApprovers = useManagerApprovers(); + + const currentLine = paymentLines[selectedIndex] ?? paymentLines[0] ?? null; + + const syncLineDefaults = (line: (typeof paymentLines)[number] | null | undefined) => { + const currentMethod = String(line?.method || '').toLowerCase(); + setMethodSelection(currentMethod === 'cash' ? 'card' : 'cash'); + setTransactionId(line?.transaction_id ? String(line.transaction_id) : ''); + setIdempotencyKey(createPaymentIdempotencyKey()); + }; + + useEffect(() => { + let cancelled = false; + api + .get('/payment-methods') + .then((res) => { + if (!cancelled) { + setCustomMethods( + (res.data?.payment_methods || []).filter((m: CustomPaymentMethod) => + Boolean(m.is_active), + ), + ); + } + }) + .catch(() => { + if (!cancelled) setCustomMethods([]); + }); + api + .get('/settings/card_transaction_id_required') + .then((res) => { + if (!cancelled) { + setCardTransactionIdRequired(res.data?.setting?.value === 'true'); + } + }) + .catch(() => { + if (!cancelled) setCardTransactionIdRequired(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const formatLineMethod = (method: string) => { + const lower = String(method || '').toLowerCase(); + if (lower === 'cash') return tPos('methodCash'); + if (lower === 'card') return tPos('methodCard'); + if (lower === 'wallet') return tPos('methodWallet'); + return method; + }; + + const selectedCustomMethod = methodSelection.startsWith('custom:') + ? (customMethods.find((m) => `custom:${m.id}` === methodSelection) ?? null) + : null; + const resolvedMethodName = selectedCustomMethod ? selectedCustomMethod.name : methodSelection; + const isCashSelected = resolvedMethodName.toLowerCase() === 'cash' && !selectedCustomMethod; + const isBuiltInCardSelected = methodSelection === 'card' && !selectedCustomMethod; + const missingRequiredCardTxId = + isBuiltInCardSelected && cardTransactionIdRequired && !transactionId.trim(); + + const canSubmit = + Boolean(effectiveBill) && + Boolean(currentLine) && + String(currentLine?.method || '').toLowerCase() !== 'wallet' && + !missingRequiredCardTxId && + !submitting && + !managerApprovers.loading && + !managerApprovers.loadFailed && + Boolean(managerApprovers.selectedApproverId) && + /^\d{4,6}$/.test(overridePin); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!effectiveBill || !currentLine || !canSubmit) return; + + setSubmitting(true); + try { + const payload: Record = { + method: selectedCustomMethod ? 'custom' : methodSelection, + ...(selectedCustomMethod ? { payment_method_id: selectedCustomMethod.id } : {}), + ...(!isCashSelected && transactionId.trim() + ? { transaction_id: transactionId.trim() } + : {}), + ...(reason.trim() ? { reason: reason.trim() } : {}), + approver_id: managerApprovers.selectedApproverId, + override_pin: overridePin, + }; + const { data } = await api.patch( + `/bills/${effectiveBill.id}/payments/${selectedIndex}/method`, + payload, + { + headers: { + 'Idempotency-Key': idempotencyKey, + }, + }, + ); + toast.success(tOrders('paymentMethodCorrected')); + onCorrected(data?.bill); + } catch (error) { + const msg = extractErrorMessage(error) || tOrders('correctPaymentMethodFailed'); + toast.error(msg); + setIdempotencyKey(createPaymentIdempotencyKey()); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+

+ {tOrders('correctPaymentMethod')} #{order.order_number} +

+

{tOrders('correctPaymentMethodHint')}

+ +
+ {bills.length > 1 && ( +
+ + +
+ )} + +
+ + {paymentLines.length > 1 ? ( + + ) : currentLine ? ( +
+ + {formatLineMethod(currentLine.method)} + + + {fmt(Number(currentLine.amount))} + +
+ ) : null} +
+ +
+ + +
+ + {!isCashSelected && ( +
+ + setTransactionId(e.target.value)} + placeholder={tOrders('transactionNumber')} + className="w-full px-3 py-2 border border-border bg-card rounded-lg text-sm text-foreground" + /> +
+ )} + +
+ + setReason(e.target.value)} + className="w-full px-3 py-2 border border-border bg-card rounded-lg text-sm text-foreground" + /> +
+ +
+ { + managerApprovers.setSelectedApproverId(id); + setOverridePin(''); + }} + loading={managerApprovers.loading} + loadFailed={managerApprovers.loadFailed} + disabled={submitting} + /> +
+ + setOverridePin(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder={tOrders('managerPin')} + className="w-full px-3 py-2 border border-border bg-card rounded-lg text-sm text-foreground" + /> +

+ {tOrders('refundApprovalPinHint')} +

+
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/orders/OrderDetailPanel.tsx b/frontend/src/components/orders/OrderDetailPanel.tsx index 252fa4789..55a3e94cd 100644 --- a/frontend/src/components/orders/OrderDetailPanel.tsx +++ b/frontend/src/components/orders/OrderDetailPanel.tsx @@ -47,8 +47,10 @@ import { Ltr } from '@/components/layout/Ltr'; import { useFormatCurrency } from '@/hooks/useFormatCurrency'; import { useFormatDate } from '@/hooks/useFormatDate'; import { parseDbTimestamp } from '@/lib/utils'; +import { useAuthStore } from '@/store/auth'; import { formatItemHeading } from '@/lib/printer/item-heading'; import { isTerminalItemStatus } from '../../../../shared/order-item-status'; +import { ROLE_ACCESS, hasRole } from '../../../../shared/role-permissions'; import PendingOrderItemEdits from './PendingOrderItemEdits'; import { orderStatusBadge, @@ -92,6 +94,7 @@ export interface OrderDetailPanelProps { onPayBill?: (bill: Bill) => void; onAddItems: (order: Order) => void; onRefund: (order: Order, bills: Bill[]) => void; + onCorrectPaymentMethod?: (order: Order, bills: Bill[]) => void; onConvertToTakeaway: (order: Order) => void; onCancelOrder: (order: Order) => void; onPrint: (billId: number) => void; @@ -151,6 +154,7 @@ function OrderDetailContent({ onPayBill, onAddItems, onRefund, + onCorrectPaymentMethod, onConvertToTakeaway, onCancelOrder, onPrint, @@ -211,6 +215,24 @@ function OrderDetailContent({ const paidBills = orderBills.filter( (b) => Number(b.paid_amount) > 0 && b.payment_status !== 'refunded', ); + const storeRole = useAuthStore( + (s) => s.currentTenant?.role ?? (s.user as { role?: string } | null)?.role, + ); + const authRole = + storeRole ?? + useAuthStore.getState?.()?.currentTenant?.role ?? + (useAuthStore.getState?.()?.user as { role?: string } | null)?.role; + const correctablePaidBills = orderBills.filter( + (b) => + b.payment_status === 'paid' && + normalizeBillPaymentDetails(b.payment_details).some( + (line) => String(line.method || '').toLowerCase() !== 'wallet', + ), + ); + const canCorrectPaymentMethod = + Boolean(onCorrectPaymentMethod) && + hasRole(authRole, ROLE_ACCESS.ownerManager) && + correctablePaidBills.length > 0; const hasEligibleRefund = paidBills.length > 0; const hasCollectedFunds = orderBills.some((candidate) => { const status = String(candidate.payment_status || ''); @@ -372,6 +394,15 @@ function OrderDetailContent({ {tOrders('downloadPrintPreview')} )} + {canCorrectPaymentMethod && onCorrectPaymentMethod && ( + onCorrectPaymentMethod(order, correctablePaidBills)} + > + + {tOrders('correctPaymentMethod')} + + )} {!['completed', 'cancelled'].includes(order.status) && ( <> @@ -931,6 +962,17 @@ function OrderDetailContent({ {tOrders('sendReceipt')} )} + {canCorrectPaymentMethod && onCorrectPaymentMethod && ( + + )} {canRefund && hasEligibleRefund && (