diff --git a/.changeset/mighty-toes-march.md b/.changeset/mighty-toes-march.md new file mode 100644 index 00000000000..307aa5ce3c7 --- /dev/null +++ b/.changeset/mighty-toes-march.md @@ -0,0 +1,9 @@ +--- +"saleor-dashboard": patch +--- + +New capture dialog for capturing payments with support for: +- Full and partial authorization status indicators +- Custom capture amount input with currency-aware decimal validation +- Order balance and transaction-level capture tracking +- Outcome prediction showing resulting order status \ No newline at end of file diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index ff67ea52de7..bf709926e1e 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -431,6 +431,10 @@ "context": "column header", "string": "Title" }, + "0YOedO": { + "context": "label for already charged amount", + "string": "Captured so far" + }, "0YjGFG": { "context": "alert message", "string": "For subscription" @@ -450,6 +454,10 @@ "context": "ProductTypeDeleteWarningDialog multiple consent label", "string": "Yes, I want to delete those products types and assigned products" }, + "0f6YvV": { + "context": "dialog title", + "string": "Capture Payment" + }, "0iMYc+": { "context": "field is optional", "string": "(Optional)" @@ -1022,6 +1030,10 @@ "4YJHut": { "string": "Clear search" }, + "4YyeCx": { + "context": "label for order total amount", + "string": "Order total" + }, "4Z0O2B": { "context": "section header title", "string": "Gift Card Timeline" @@ -1612,6 +1624,10 @@ "context": "ordered product sku", "string": "SKU" }, + "8JEG80": { + "context": "warning when authorized is less than total", + "string": "The remaining authorization doesn't cover the balance. {shortfall} will need a separate payment." + }, "8LWaFr": { "context": "dialog content", "string": "{counter,plural,one{Are you sure you want to unpublish this model?} other{Are you sure you want to unpublish {displayQuantity} models?}}" @@ -2065,6 +2081,10 @@ "context": "no warehouses info", "string": "There are no warehouses set up for this product. To add stock quantity to the product configure a warehouse or use existing one by clicking button below." }, + "BJRu4V": { + "context": "pill status for partially captured outcome", + "string": "Partially captured" + }, "BJtUQI": { "context": "button", "string": "Add" @@ -2857,6 +2877,10 @@ "context": "tooltip", "string": "Checkout reservation time threshold is enabled in settings." }, + "G9y5Ze": { + "context": "pill status for fully captured outcome", + "string": "Fully captured" + }, "GA+Djy": { "string": "Are you sure you want to delete these voucher codes?" }, @@ -3112,6 +3136,10 @@ "context": "cta button label", "string": "Get in touch" }, + "HSYM17": { + "context": "outcome prediction showing resulting order status after capture", + "string": "This will result in {status} order" + }, "HSmg1/": { "context": "gift cards section name", "string": "Gift Cards" @@ -3185,6 +3213,10 @@ "HvJPcU": { "string": "Category deleted" }, + "HwIhau": { + "context": "status pill for fully authorized payment", + "string": "Fully Authorized" + }, "HwTMFL": { "string": "Go to channels" }, @@ -3285,6 +3317,10 @@ "ITYiRy": { "string": "Go to collections" }, + "IU1lif": { + "context": "radio option for custom capture amount", + "string": "Custom amount" + }, "IUWJKt": { "context": "order was discounted event title", "string": "Order was discounted" @@ -3703,6 +3739,10 @@ "L87bp7": { "string": "Order payment successfully voided" }, + "L8J/jr": { + "context": "status pill when order is fully paid", + "string": "Fully Captured" + }, "L8seEc": { "string": "Subtotal" }, @@ -3939,10 +3979,18 @@ "context": "customer input label", "string": "Customer" }, + "MhlYkx": { + "context": "label for available authorization amount", + "string": "Available to capture (authorized)" + }, "MjUyhA": { "context": "section subheader", "string": "Active member since {date}" }, + "Mm/Stj": { + "context": "hint showing maximum allowed custom amount", + "string": "Max: {amount}" + }, "MmGlkp": { "context": "dialog header", "string": "Unassign Collections From Voucher" @@ -4230,6 +4278,10 @@ "context": "tab name", "string": "All staff members" }, + "OUMqG1": { + "context": "label for remaining balance to capture", + "string": "Remaining balance" + }, "OUX4LB": { "context": "input label", "string": "Model type" @@ -4658,6 +4710,10 @@ "context": "add new refund button", "string": "New refund" }, + "R/YHMH": { + "context": "label for amount already captured from this transaction", + "string": "Already captured" + }, "R4IIw1": { "context": "tracking number of the shipment", "string": "Tracking number" @@ -4928,6 +4984,10 @@ "context": "product filter label", "string": "Product" }, + "SnV3LR": { + "context": "error when no authorization exists", + "string": "No payment has been authorized for this order. The full amount of {amount} cannot be captured." + }, "SpngiS": { "context": "sale status", "string": "Status" @@ -5125,6 +5185,10 @@ "context": "order expiration card description", "string": "The time in days after expired orders will be deleted. Allowed range between 1 and 120." }, + "U0IK0G": { + "context": "label for authorized amount", + "string": "Authorized" + }, "U2DyeR": { "string": "Are you sure you want to delete structure {menuName}?" }, @@ -5774,6 +5838,10 @@ "context": "product pricing, section header", "string": "Pricing" }, + "XrliJg": { + "context": "label for amount selection", + "string": "Select amount to capture:" + }, "Xsh2Pa": { "context": "column picker search input placeholder", "string": "Search for columns" @@ -6325,6 +6393,10 @@ "context": "range input label", "string": "Postal codes (end)" }, + "ayylzh": { + "context": "status pill for partial authorization", + "string": "Partial Authorisation" + }, "b+jcaN": { "string": "There are still fulfillments created for this order. Cancel the fulfillments first before you cancel the order." }, @@ -6409,6 +6481,10 @@ "context": "button", "string": "Create permission group" }, + "bRXgSC": { + "context": "capture button with amount", + "string": "Capture {amount}" + }, "bS7A8u": { "context": "add tracking button", "string": "Add tracking" @@ -7600,6 +7676,10 @@ "context": "product price", "string": "Select channel" }, + "jhyt3I": { + "context": "label for max capturable amount when partial authorization", + "string": "Remaining max (authorized)" + }, "jiXbx5": { "context": "fulfillment status canceled", "string": "Canceled" @@ -8140,6 +8220,10 @@ "mvVmbJ": { "string": "Install extension from manifest" }, + "mxGY7T": { + "context": "status pill for no authorization", + "string": "No Authorization" + }, "mxtAFx": { "string": "Are you sure you want to delete draft #{orderNumber}?" }, @@ -8769,6 +8853,10 @@ "context": "bulk delete label", "string": "Delete" }, + "qlfssi": { + "context": "label for remaining amount customer owes", + "string": "Balance due" + }, "qov29K": { "context": "dialog content", "string": "Select one of customer addresses or add a new address:" @@ -9143,6 +9231,10 @@ "tR+UuE": { "string": "User doesn't exist. Please check your email in URL" }, + "tS2K/N": { + "context": "radio option for capturing order total", + "string": "Order total" + }, "tTuCYj": { "context": "all gift cards label", "string": "All Gift Cards" @@ -9262,6 +9354,10 @@ "context": "shipping zones configuration", "string": "Change default weight unit" }, + "u7ShY+": { + "context": "pill status for overcaptured outcome", + "string": "Overcaptured" + }, "u9/vj9": { "context": "webhook input label", "string": "Target URL" @@ -9445,6 +9541,10 @@ "v3WWK+": { "string": "Status is invalid" }, + "v8e93p": { + "context": "hint for order total option", + "string": "Matches what customer owes" + }, "v9D1pm": { "context": "automatic completion cut-off date info message", "string": "Setting a cut-off date will not stop checkouts that are already in the process of being completed." diff --git a/src/auth/utils.ts b/src/auth/utils.ts index 19efcb42f03..61bf6cb9584 100644 --- a/src/auth/utils.ts +++ b/src/auth/utils.ts @@ -1,5 +1,5 @@ import { ApolloError, ServerError } from "@apollo/client/core"; -import { IMessage, IMessageContext } from "@dashboard/components/messages"; +import { IMessageContext } from "@dashboard/components/messages"; import { commonMessages } from "@dashboard/intl"; import { getMutationErrors, parseLogMessage } from "@dashboard/misc"; import { getAppMountUriForRedirect } from "@dashboard/utils/urls"; @@ -60,7 +60,7 @@ export const handleNestedMutationErrors = ({ }: { data: any; intl: IntlShape; - notify: (message: IMessage) => void; + notify: IMessageContext; }) => { const mutationErrors = getMutationErrors({ data }); diff --git a/src/components/Callout/Callout.tsx b/src/components/Callout/Callout.tsx index dbf42137d42..c4493ef5a8d 100644 --- a/src/components/Callout/Callout.tsx +++ b/src/components/Callout/Callout.tsx @@ -1,58 +1,66 @@ -import { ExclamationIcon } from "@dashboard/icons/ExclamationIcon"; -import { Box } from "@saleor/macaw-ui-next"; +import { iconSize, iconStrokeWidthBySize } from "@dashboard/components/icons"; +import { getStatusColor, PillStatusType } from "@dashboard/misc"; +import { Box, Text, useTheme } from "@saleor/macaw-ui-next"; +import { AlertTriangle, CircleAlert, Info, LucideIcon } from "lucide-react"; import { ReactNode } from "react"; -import { DashboardCard } from "../Card"; +type CalloutType = "info" | "warning" | "error"; -type CalloutType = "info" | "warning"; +interface CalloutStyles { + status: PillStatusType; + iconColor: "warning1" | "critical1" | "default1"; + Icon: LucideIcon; +} -const warningStylesBox = { - backgroundColor: "warning1", - borderColor: "warning1", -} as const; - -const warningStylesIcon = { - color: "warning1", -} as const; - -const gridTemplate = ` - "icon title" - "empty content" -`; +const calloutStylesMap: Record = { + warning: { + status: "warning", + iconColor: "warning1", + Icon: CircleAlert, + }, + error: { + status: "error", + iconColor: "critical1", + Icon: AlertTriangle, + }, + info: { + status: "neutral", + iconColor: "default1", + Icon: Info, + }, +}; -export const Callout = ({ - children, - title, - type, -}: { - children: ReactNode; +interface CalloutProps { + children?: ReactNode; title: ReactNode; type: CalloutType; -}) => { - const boxStyles = type === "warning" ? warningStylesBox : null; - const iconStyles = type === "warning" ? warningStylesIcon : null; +} + +export const Callout = ({ children, title, type }: CalloutProps): JSX.Element => { + const { theme: currentTheme } = useTheme(); + const { status, iconColor, Icon } = calloutStylesMap[type]; + const backgroundColor = getStatusColor({ status, currentTheme }).base; return ( - - - - - - {title} - - - {children} - - + + + + + {title} + {children && ( + + {children} + + )} + + ); }; diff --git a/src/components/Callout/messages.ts b/src/components/Callout/messages.ts index 012b5169465..029f5fce83d 100644 --- a/src/components/Callout/messages.ts +++ b/src/components/Callout/messages.ts @@ -1,5 +1,7 @@ import { defineMessages } from "react-intl"; +// Re-export common messages for backward compatibility +// TODO: Remove and use `commonMessages` from @dashboard/intl export const calloutTitleMessages = defineMessages({ info: { defaultMessage: "Info", diff --git a/src/components/PriceField/utils.test.ts b/src/components/PriceField/utils.test.ts new file mode 100644 index 00000000000..3d4676938ee --- /dev/null +++ b/src/components/PriceField/utils.test.ts @@ -0,0 +1,141 @@ +import { + getCurrencyDecimalPoints, + limitDecimalPlaces, + normalizeDecimalSeparator, + parseDecimalValue, +} from "./utils"; + +describe("normalizeDecimalSeparator", () => { + it("converts comma to dot", () => { + expect(normalizeDecimalSeparator("10,50")).toBe("10.50"); + }); + + it("leaves dot unchanged", () => { + expect(normalizeDecimalSeparator("10.50")).toBe("10.50"); + }); + + it("handles integers without separator", () => { + expect(normalizeDecimalSeparator("100")).toBe("100"); + }); + + it("handles empty string", () => { + expect(normalizeDecimalSeparator("")).toBe(""); + }); + + it("handles European format with thousand separator (dot) and decimal comma", () => { + // 1.234,56 = one thousand two hundred thirty-four and 56 cents + expect(normalizeDecimalSeparator("1.234,56")).toBe("1234.56"); + }); + + it("handles US format with thousand separator (comma) and decimal dot", () => { + // 1,234.56 = one thousand two hundred thirty-four and 56 cents + expect(normalizeDecimalSeparator("1,234.56")).toBe("1234.56"); + }); + + it("handles large European format numbers", () => { + expect(normalizeDecimalSeparator("1.234.567,89")).toBe("1234567.89"); + }); + + it("handles large US format numbers", () => { + expect(normalizeDecimalSeparator("1,234,567.89")).toBe("1234567.89"); + }); + + it("handles US thousands-only format without decimal", () => { + // 1,234,567 = one million two hundred thirty-four thousand five hundred sixty-seven + expect(normalizeDecimalSeparator("1,234,567")).toBe("1234567"); + }); + + it("handles European thousands-only format without decimal", () => { + // 1.234.567 = one million two hundred thirty-four thousand five hundred sixty-seven + expect(normalizeDecimalSeparator("1.234.567")).toBe("1234567"); + }); +}); + +describe("parseDecimalValue", () => { + it("parses dot-separated value", () => { + expect(parseDecimalValue("10.50")).toBe(10.5); + }); + + it("parses comma-separated value", () => { + expect(parseDecimalValue("10,50")).toBe(10.5); + }); + + it("parses integer", () => { + expect(parseDecimalValue("100")).toBe(100); + }); + + it("returns 0 for empty string", () => { + expect(parseDecimalValue("")).toBe(0); + }); + + it("returns 0 for invalid input", () => { + expect(parseDecimalValue("abc")).toBe(0); + }); + + it("handles negative values", () => { + expect(parseDecimalValue("-10.50")).toBe(-10.5); + }); +}); + +describe("limitDecimalPlaces", () => { + it("limits decimal places with dot separator", () => { + expect(limitDecimalPlaces("10.12345", 2)).toBe("10.12"); + }); + + it("limits decimal places with comma separator", () => { + expect(limitDecimalPlaces("10,12345", 2)).toBe("10,12"); + }); + + it("preserves original separator when limiting", () => { + expect(limitDecimalPlaces("10,999", 2)).toBe("10,99"); + expect(limitDecimalPlaces("10.999", 2)).toBe("10.99"); + }); + + it("returns integer when maxDecimalPlaces is 0", () => { + expect(limitDecimalPlaces("10.50", 0)).toBe("10"); + expect(limitDecimalPlaces("10,50", 0)).toBe("10"); + }); + + it("returns value unchanged if decimal places are within limit", () => { + expect(limitDecimalPlaces("10.12", 2)).toBe("10.12"); + expect(limitDecimalPlaces("10.1", 2)).toBe("10.1"); + }); + + it("returns value unchanged if no decimal part", () => { + expect(limitDecimalPlaces("100", 2)).toBe("100"); + }); + + it("handles three decimal places for currencies like KWD", () => { + expect(limitDecimalPlaces("10.1234", 3)).toBe("10.123"); + }); + + it("handles zero decimal places for currencies like JPY", () => { + expect(limitDecimalPlaces("1000.99", 0)).toBe("1000"); + }); +}); + +describe("getCurrencyDecimalPoints", () => { + it("returns 2 for USD", () => { + expect(getCurrencyDecimalPoints("USD")).toBe(2); + }); + + it("returns 2 for EUR", () => { + expect(getCurrencyDecimalPoints("EUR")).toBe(2); + }); + + it("returns 0 for JPY (Japanese Yen)", () => { + expect(getCurrencyDecimalPoints("JPY")).toBe(0); + }); + + it("returns 3 for KWD (Kuwaiti Dinar)", () => { + expect(getCurrencyDecimalPoints("KWD")).toBe(3); + }); + + it("returns 2 as fallback for undefined currency", () => { + expect(getCurrencyDecimalPoints(undefined)).toBe(2); + }); + + it("returns 2 as fallback for invalid currency code", () => { + expect(getCurrencyDecimalPoints("INVALID")).toBe(2); + }); +}); diff --git a/src/components/PriceField/utils.ts b/src/components/PriceField/utils.ts index a966bd3073a..ca684d12b40 100644 --- a/src/components/PriceField/utils.ts +++ b/src/components/PriceField/utils.ts @@ -32,3 +32,75 @@ export const getCurrencyDecimalPoints = (currency?: string) => { export const findPriceSeparator = (input: string) => SEPARATOR_CHARACTERS.find(separator => input.includes(separator)); + +/** + * Normalizes decimal separator to JavaScript standard (dot). + * Handles different locale formats: + * - European: "1.234,56" → "1234.56" (comma decimal, dot thousand) + * - US: "1,234.56" → "1234.56" (dot decimal, comma thousand) + * - Simple: "10,50" or "10.50" → "10.50" + * - US thousands only: "1,234,567" → "1234567" + * - European thousands only: "1.234.567" → "1234567" + */ +export const normalizeDecimalSeparator = (value: string): string => { + const commaCount = (value.match(/,/g) || []).length; + const dotCount = (value.match(/\./g) || []).length; + + if (commaCount > 0 && dotCount > 0) { + // Both separators present - last one is decimal, other is thousand + const lastComma = value.lastIndexOf(","); + const lastDot = value.lastIndexOf("."); + + if (lastComma > lastDot) { + // European format: 1.234,56 → remove dots, convert comma to dot + return value.replace(/\./g, "").replace(",", "."); + } else { + // US format: 1,234.56 → remove commas + return value.replace(/,/g, ""); + } + } + + // Multiple commas = US thousands separators (e.g., "1,234,567") + if (commaCount > 1) { + return value.replace(/,/g, ""); + } + + // Multiple dots = European thousands separators (e.g., "1.234.567") + if (dotCount > 1) { + return value.replace(/\./g, ""); + } + + // Single comma (European decimal) or single dot (US decimal) or no separator + return value.replace(",", "."); +}; + +/** + * Parses a decimal string value to a number, handling locale-specific separators. + * Returns 0 if the value cannot be parsed. + */ +export const parseDecimalValue = (value: string): number => + parseFloat(normalizeDecimalSeparator(value)) || 0; + +/** + * Limits decimal places in a string value, preserving the user's original separator. + * Useful for input validation while typing. + */ +export const limitDecimalPlaces = (value: string, maxDecimalPlaces: number): string => { + const normalized = normalizeDecimalSeparator(value); + const separator = value.includes(",") ? "," : "."; + const [integerPart, decimalPart] = normalized.split("."); + + if (!decimalPart) { + return value; + } + + if (maxDecimalPlaces === 0) { + return integerPart; + } + + if (decimalPart.length > maxDecimalPlaces) { + return `${integerPart}${separator}${decimalPart.slice(0, maxDecimalPlaces)}`; + } + + return value; +}; diff --git a/src/extensions/views/AddCustomExtension/AddCustomExtension.test.tsx b/src/extensions/views/AddCustomExtension/AddCustomExtension.test.tsx index f0818f0815e..885f3bc4cda 100644 --- a/src/extensions/views/AddCustomExtension/AddCustomExtension.test.tsx +++ b/src/extensions/views/AddCustomExtension/AddCustomExtension.test.tsx @@ -25,6 +25,11 @@ class ResizeObserverMock { global.ResizeObserver = ResizeObserverMock; +jest.mock("@saleor/macaw-ui-next", () => ({ + ...(jest.requireActual("@saleor/macaw-ui-next") as object), + useTheme: () => ({ theme: "default" }), +})); + jest.mock("react-router-dom", () => ({ Link: ({ children, to, ...props }: { children: React.ReactNode; to: string }) => ( diff --git a/src/misc.ts b/src/misc.ts index cff05c1bfcf..7f9c88d8789 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -293,7 +293,16 @@ export function getMutationStatus( diff --git a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.test.tsx b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.test.tsx new file mode 100644 index 00000000000..ddaa980f565 --- /dev/null +++ b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.test.tsx @@ -0,0 +1,465 @@ +import { ConfirmButtonTransitionState } from "@dashboard/components/ConfirmButton"; +import { OrderErrorCode, OrderErrorFragment } from "@dashboard/graphql"; +import { IMoney } from "@dashboard/utils/intl"; +import Wrapper from "@test/wrapper"; +import { fireEvent, render, screen, within } from "@testing-library/react"; + +import { OrderCaptureDialog, OrderCaptureDialogProps } from "./OrderCaptureDialog"; + +const createMoney = (amount: number, currency = "USD"): IMoney => ({ + amount, + currency, +}); + +const defaultProps: OrderCaptureDialogProps = { + confirmButtonState: "default" as ConfirmButtonTransitionState, + orderTotal: createMoney(100), + authorizedAmount: createMoney(100), + onClose: jest.fn(), + onSubmit: jest.fn(), +}; + +const renderDialog = (props: Partial = {}) => + render( + + + , + ); + +describe("OrderCaptureDialog", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("rendering", () => { + it("renders the dialog when open is true", () => { + // Arrange & Act + renderDialog(); + + // Assert + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("Capture Payment")).toBeInTheDocument(); + }); + + it("displays order total label", () => { + // Arrange & Act + renderDialog({ orderTotal: createMoney(150) }); + + // Assert + expect(screen.getByText("Order total")).toBeInTheDocument(); + }); + + it("displays available to capture label", () => { + // Arrange & Act + renderDialog({ authorizedAmount: createMoney(80) }); + + // Assert + expect(screen.getByText("Available to capture (authorized)")).toBeInTheDocument(); + }); + }); + + describe("authorization status", () => { + it("shows 'Fully Authorized' pill when authorized >= remaining", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(100), + }); + + // Assert + expect(screen.getByText("Fully Authorized")).toBeInTheDocument(); + }); + + it("shows 'Partial Authorisation' pill when authorized < remaining", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(50), + }); + + // Assert + expect(screen.getByText("Partial Authorisation")).toBeInTheDocument(); + }); + + it("shows warning callout for partial authorization with shortfall", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(50), + }); + + // Assert + expect( + screen.getByText(/The remaining authorization doesn't cover the balance/), + ).toBeInTheDocument(); + }); + + it("shows 'No Authorization' pill when authorized is 0", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(0), + }); + + // Assert + expect(screen.getByText("No Authorization")).toBeInTheDocument(); + }); + + it("shows error callout when no authorization", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(0), + }); + + // Assert + expect(screen.getByText(/No payment has been authorized for this order/)).toBeInTheDocument(); + }); + + it("shows 'Fully Captured' pill when order is fully paid", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(0), + chargedAmount: createMoney(100), + }); + + // Assert + expect(screen.getByText("Fully Captured")).toBeInTheDocument(); + }); + }); + + describe("radio options", () => { + it("defaults to first option for full authorization", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(100), + }); + + // Assert + const radioGroup = screen.getByRole("radiogroup"); + const selectedRadio = within(radioGroup).getByRole("radio", { checked: true }); + + expect(selectedRadio).toHaveAttribute("value", "orderTotal"); + }); + + it("shows 'Remaining max (authorized)' label for partial authorization", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(50), + }); + + // Assert + expect(screen.getByText("Remaining max (authorized)")).toBeInTheDocument(); + }); + + it("disables radio options when no authorization", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(0), + }); + + // Assert + const radioGroup = screen.getByRole("radiogroup"); + const radios = within(radioGroup).getAllByRole("radio"); + + radios.forEach(radio => { + expect(radio).toBeDisabled(); + }); + }); + + it("allows selecting custom amount option", async () => { + // Arrange + renderDialog(); + + // Act + const customRadio = screen.getByRole("radio", { name: /Custom amount/i }); + + fireEvent.click(customRadio); + + // Assert + expect(customRadio).toBeChecked(); + }); + }); + + describe("custom amount input", () => { + const getCustomInput = () => { + const dialog = screen.getByRole("dialog"); + + return dialog.querySelector('input[type="text"]') as HTMLInputElement; + }; + + it("is disabled when custom option is not selected", () => { + // Arrange & Act + renderDialog(); + + // Assert + const input = getCustomInput(); + + expect(input).toBeDisabled(); + }); + + it("is enabled when custom option is selected", () => { + // Arrange + renderDialog(); + + // Act + const customRadio = screen.getByRole("radio", { name: /Custom amount/i }); + + fireEvent.click(customRadio); + + // Assert + const input = getCustomInput(); + + expect(input).not.toBeDisabled(); + }); + + it("shows max capturable hint", () => { + // Arrange & Act + renderDialog({ + authorizedAmount: createMoney(75), + }); + + // Assert + expect(screen.getByText(/Max:/)).toBeInTheDocument(); + }); + + it("accepts valid custom amount input", () => { + // Arrange + renderDialog(); + + const customRadio = screen.getByRole("radio", { name: /Custom amount/i }); + + fireEvent.click(customRadio); + + // Act + const input = getCustomInput(); + + fireEvent.change(input, { target: { value: "50" } }); + + // Assert + expect(input).toHaveValue("50"); + }); + + it("limits decimal places based on currency", () => { + // Arrange + renderDialog(); + + const customRadio = screen.getByRole("radio", { name: /Custom amount/i }); + + fireEvent.click(customRadio); + + // Act + const input = getCustomInput(); + + fireEvent.change(input, { target: { value: "50.999" } }); + + // Assert - USD has 2 decimal places + expect(input).toHaveValue("50.99"); + }); + }); + + describe("submit button", () => { + it("shows capture amount in button text", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(100), + }); + + // Assert + expect(screen.getByRole("button", { name: /Capture/i })).toBeInTheDocument(); + }); + + it("calls onSubmit with correct amount for full authorization", () => { + // Arrange + const onSubmit = jest.fn(); + + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(100), + onSubmit, + }); + + // Act + const captureButton = screen.getByRole("button", { name: /Capture/i }); + + fireEvent.click(captureButton); + + // Assert + expect(onSubmit).toHaveBeenCalledWith(100); + }); + + it("calls onSubmit with max available for partial authorization", () => { + // Arrange + const onSubmit = jest.fn(); + + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(50), + onSubmit, + }); + + // Act + const captureButton = screen.getByRole("button", { name: /Capture/i }); + + fireEvent.click(captureButton); + + // Assert + expect(onSubmit).toHaveBeenCalledWith(50); + }); + + it("calls onSubmit with custom amount when selected", () => { + // Arrange + const onSubmit = jest.fn(); + + renderDialog({ onSubmit }); + + const customRadio = screen.getByRole("radio", { name: /Custom amount/i }); + + fireEvent.click(customRadio); + + const dialog = screen.getByRole("dialog"); + const input = dialog.querySelector('input[type="text"]') as HTMLInputElement; + + fireEvent.change(input, { target: { value: "25" } }); + + // Act + const captureButton = screen.getByRole("button", { name: /Capture/i }); + + fireEvent.click(captureButton); + + // Assert + expect(onSubmit).toHaveBeenCalledWith(25); + }); + + it("is disabled when no authorization", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(0), + }); + + // Assert + const captureButton = screen.getByRole("button", { name: /Capture/i }); + + expect(captureButton).toBeDisabled(); + }); + + it("is disabled when custom amount exceeds max", () => { + // Arrange + renderDialog({ + authorizedAmount: createMoney(50), + }); + + const customRadio = screen.getByRole("radio", { name: /Custom amount/i }); + + fireEvent.click(customRadio); + + const dialog = screen.getByRole("dialog"); + const input = dialog.querySelector('input[type="text"]') as HTMLInputElement; + + fireEvent.change(input, { target: { value: "100" } }); + + // Assert + const captureButton = screen.getByRole("button", { name: /Capture/i }); + + expect(captureButton).toBeDisabled(); + }); + }); + + describe("close button", () => { + it("calls onClose when back button is clicked", () => { + // Arrange + const onClose = jest.fn(); + + renderDialog({ onClose }); + + // Act + const backButton = screen.getByRole("button", { name: /back/i }); + + fireEvent.click(backButton); + + // Assert + expect(onClose).toHaveBeenCalled(); + }); + }); + + describe("error handling", () => { + it("displays error messages when provided", () => { + // Arrange + const errors = [ + { + __typename: "OrderError" as const, + code: OrderErrorCode.CAPTURE_INACTIVE_PAYMENT, + field: null, + addressType: null, + message: null, + orderLines: null, + }, + ] as OrderErrorFragment[]; + + // Act + renderDialog({ errors }); + + // Assert + // The error message will be rendered by getOrderErrorMessage utility + const dialog = screen.getByRole("dialog"); + + expect(dialog).toBeInTheDocument(); + }); + }); + + describe("outcome prediction", () => { + it("shows outcome message when capturing full balance", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(100), + }); + + // Assert - outcome prediction message is shown + expect(screen.getByText(/This will result in/)).toBeInTheDocument(); + }); + + it("shows outcome message when capturing partial balance", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(50), + }); + + // Assert - outcome prediction message is shown + expect(screen.getByText(/This will result in/)).toBeInTheDocument(); + }); + }); + + describe("with charged amount", () => { + it("displays 'Captured so far' label when there is prior charged amount", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(50), + chargedAmount: createMoney(30), + }); + + // Assert + expect(screen.getByText("Captured so far")).toBeInTheDocument(); + }); + + it("displays 'Balance due' label with prior charges", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(50), + chargedAmount: createMoney(30), + }); + + // Assert + expect(screen.getByText("Balance due")).toBeInTheDocument(); + }); + }); +}); diff --git a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx new file mode 100644 index 00000000000..6ed1e7bb957 --- /dev/null +++ b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx @@ -0,0 +1,524 @@ +import BackButton from "@dashboard/components/BackButton"; +import { Callout } from "@dashboard/components/Callout/Callout"; +import { ConfirmButton, ConfirmButtonTransitionState } from "@dashboard/components/ConfirmButton"; +import { iconSize, iconStrokeWidthBySize } from "@dashboard/components/icons"; +import { DashboardModal } from "@dashboard/components/Modal"; +import Money from "@dashboard/components/Money"; +import { Pill } from "@dashboard/components/Pill"; +import { + getCurrencyDecimalPoints, + limitDecimalPlaces, + parseDecimalValue, +} from "@dashboard/components/PriceField/utils"; +import { OrderErrorFragment, TransactionRequestActionErrorFragment } from "@dashboard/graphql"; +import getOrderErrorMessage from "@dashboard/utils/errors/order"; +import { getOrderTransactionErrorMessage } from "@dashboard/utils/errors/transaction"; +import { IMoney } from "@dashboard/utils/intl"; +import { Box, Input, RadioGroup, Text } from "@saleor/macaw-ui-next"; +import { AlertTriangle, Box as BoxIcon, CheckCircle2, CircleAlert, CreditCard } from "lucide-react"; +import { ChangeEvent, useMemo, useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; + +import { messages } from "./messages"; +import { AuthorizationStatus, useCaptureState } from "./useCaptureState"; + +type CaptureError = OrderErrorFragment | TransactionRequestActionErrorFragment; + +const isTransactionError = ( + error: CaptureError, +): error is TransactionRequestActionErrorFragment => { + // TransactionRequestActionErrorFragment has a different __typename + return error.__typename === "TransactionRequestActionError"; +}; + +export type CaptureAmountOption = "orderTotal" | "custom"; + +export interface OrderCaptureDialogProps { + confirmButtonState: ConfirmButtonTransitionState; + orderTotal: IMoney; + authorizedAmount: IMoney; + /** Amount already charged/captured - used for display */ + chargedAmount?: IMoney; + /** + * Order's total balance (for multi-transaction orders). + * Negative = customer owes money, Positive = overpaid. + * When provided, used instead of (orderTotal - chargedAmount) for remaining calculation. + */ + orderBalance?: IMoney; + /** Server errors from the capture mutation (supports both Legacy and Transactions API errors) */ + errors?: CaptureError[]; + onClose: () => void; + onSubmit: (amount: number) => void; +} + +export const OrderCaptureDialog = ({ + confirmButtonState, + orderTotal, + authorizedAmount, + chargedAmount, + orderBalance, + errors = [], + onClose, + onSubmit, +}: OrderCaptureDialogProps): JSX.Element => { + const intl = useIntl(); + + const currency = orderTotal.currency; + + // Use custom hook for capture state calculations + const captureState = useCaptureState({ + orderTotal, + authorizedAmount, + chargedAmount, + orderBalance, + }); + + const { + availableToCapture, + alreadyCharged, + remainingToPay, + status: authStatus, + maxCapturable, + shortfall, + canCaptureOrderTotal, + orderTotalCaptured, + } = captureState; + + const totalAmount = orderTotal.amount; + + // Default selection: always prefer "orderTotal" unless it's disabled + const isFirstOptionDisabled = authStatus === "none" || authStatus === "charged"; + const getDefaultOption = (): CaptureAmountOption => { + // Always default to orderTotal (first option) unless it's disabled + return isFirstOptionDisabled ? "custom" : "orderTotal"; + }; + + const getDefaultCustomAmount = (): number => { + if (authStatus === "none" || authStatus === "charged") { + return 0; + } + + if (authStatus === "partial") { + // Default to max capturable (remaining auth) + return availableToCapture; + } + + // Default to remaining amount to pay + return remainingToPay; + }; + + const [selectedOption, setSelectedOption] = useState(getDefaultOption); + const [customAmount, setCustomAmount] = useState(getDefaultCustomAmount); + // String representation for the input field (allows user to type intermediate values like "10.") + const [customAmountInput, setCustomAmountInput] = useState( + String(getDefaultCustomAmount()), + ); + + // Get max decimal places for this currency (e.g., 2 for USD, 0 for JPY, 3 for KWD) + const maxDecimalPlaces = useMemo(() => getCurrencyDecimalPoints(currency), [currency]); + + const handleCustomAmountChange = (e: ChangeEvent): void => { + const limitedValue = limitDecimalPlaces(e.target.value, maxDecimalPlaces); + + setCustomAmountInput(limitedValue); + setCustomAmount(parseDecimalValue(limitedValue)); + }; + + const getSelectedAmount = (): number => { + switch (selectedOption) { + case "orderTotal": + // For partial auth, capture max available; for full, capture remaining balance + return authStatus === "partial" ? availableToCapture : remainingToPay; + case "custom": + return customAmount; + } + }; + + const selectedAmount = getSelectedAmount(); + const isCustomAmountInRange = customAmount > 0 && customAmount <= maxCapturable; + const isCustomAmountValid = selectedOption !== "custom" || isCustomAmountInRange; + const showCustomAmountError = + selectedOption === "custom" && + authStatus !== "none" && + authStatus !== "charged" && + !isCustomAmountInRange; + const canSubmit = + authStatus !== "none" && authStatus !== "charged" && isCustomAmountValid && selectedAmount > 0; + + const handleSubmit = (): void => { + if (canSubmit) { + onSubmit(selectedAmount); + } + }; + + const formatMoney = (amount: number): JSX.Element => ( + + + + ); + + // Calculate predicted outcome status after capture (order-wide) + type OutcomeStatus = "fullyCharged" | "partiallyCharged" | "overcharged"; + + const getOutcomeStatus = (): OutcomeStatus => { + const totalAfterCapture = orderTotalCaptured + selectedAmount; + + if (totalAfterCapture > totalAmount) { + return "overcharged"; + } else if (totalAfterCapture >= totalAmount) { + return "fullyCharged"; + } else { + return "partiallyCharged"; + } + }; + + const outcomeStatus = getOutcomeStatus(); + + const getStatusPill = (): JSX.Element => { + switch (authStatus) { + case "charged": + return ( + } + /> + ); + case "full": + return ( + } + /> + ); + case "partial": + return ( + } + /> + ); + case "none": + return ( + } + /> + ); + } + }; + + type AuthorizationColor = "success1" | "warning1" | "critical1"; + + const authStatusColorMap: Record = { + charged: "success1", + full: "success1", + partial: "warning1", + none: "critical1", + }; + + const authorizedAmountColor = authStatusColorMap[authStatus]; + + return ( + + + + + + {getStatusPill()} + + + + + {/* Summary box with order and payment sections */} + + {/* Order section */} + + + + + + + + + {formatMoney(totalAmount)} + + {orderTotalCaptured > 0 && ( + + + {/* Spacer to align with icon above */} + + + + + {formatMoney(orderTotalCaptured)} + + )} + + + {/* Spacer to align with icon above */} + + + + + {formatMoney(remainingToPay)} + + + + {/* Transaction section */} + + + + + + + + + + + {formatMoney(availableToCapture)} + + + {alreadyCharged > 0 && ( + + + {/* Spacer to align with icon above */} + + + + + {formatMoney(alreadyCharged)} + + )} + + + {/* Warning/Error messages */} + {authStatus === "partial" && ( + + {formatMoney(shortfall)}, + }} + /> + + } + /> + )} + + {authStatus === "none" && ( + + {formatMoney(remainingToPay)}, + }} + /> + + } + /> + )} + + + + {/* Radio options - primary section */} + + + + + + setSelectedOption(value as CaptureAmountOption)} + > + + {/* Order Total / Remaining Balance / Remaining Max option */} + + + + + 0 + ? messages.remainingBalance + : messages.optionOrderTotal)} + /> + + + + {formatMoney(authStatus === "partial" ? availableToCapture : remainingToPay)} + + + {canCaptureOrderTotal && ( + + + + + + )} + + + {/* Custom amount option */} + + + + + + + + + + + + + {currency} + + } + /> + + + + + + + + + + + + {/* Outcome prediction */} + {canSubmit && selectedAmount > 0 && ( + + + + ), + }} + /> + + + )} + + {errors.length > 0 && ( + + {errors.map((error, index) => ( + + {isTransactionError(error) + ? getOrderTransactionErrorMessage(error, intl) + : getOrderErrorMessage(error, intl)} + + ))} + + )} + + + + + + + + + + ); +}; + +OrderCaptureDialog.displayName = "OrderCaptureDialog"; diff --git a/src/orders/components/OrderCaptureDialog/messages.ts b/src/orders/components/OrderCaptureDialog/messages.ts new file mode 100644 index 00000000000..dc836db648a --- /dev/null +++ b/src/orders/components/OrderCaptureDialog/messages.ts @@ -0,0 +1,131 @@ +import { defineMessages } from "react-intl"; + +export const messages = defineMessages({ + title: { + id: "0f6YvV", + defaultMessage: "Capture Payment", + description: "dialog title", + }, + statusFullyAuthorized: { + id: "HwIhau", + defaultMessage: "Fully Authorized", + description: "status pill for fully authorized payment", + }, + statusPartial: { + id: "ayylzh", + defaultMessage: "Partial Authorisation", + description: "status pill for partial authorization", + }, + statusNoAuthorization: { + id: "mxGY7T", + defaultMessage: "No Authorization", + description: "status pill for no authorization", + }, + statusFullyCaptured: { + id: "L8J/jr", + defaultMessage: "Fully Captured", + description: "status pill when order is fully paid", + }, + orderTotal: { + id: "4YyeCx", + defaultMessage: "Order total", + description: "label for order total amount", + }, + authorized: { + id: "U0IK0G", + defaultMessage: "Authorized", + description: "label for authorized amount", + }, + capturedSoFar: { + id: "0YOedO", + defaultMessage: "Captured so far", + description: "label for already charged amount", + }, + balanceDue: { + id: "qlfssi", + defaultMessage: "Balance due", + description: "label for remaining amount customer owes", + }, + availableToCapture: { + id: "MhlYkx", + defaultMessage: "Available to capture (authorized)", + description: "label for available authorization amount", + }, + transactionCaptured: { + id: "R/YHMH", + defaultMessage: "Already captured", + description: "label for amount already captured from this transaction", + }, + remainingBalance: { + id: "OUMqG1", + defaultMessage: "Remaining balance", + description: "label for remaining balance to capture", + }, + remainingMax: { + id: "jhyt3I", + defaultMessage: "Remaining max (authorized)", + description: "label for max capturable amount when partial authorization", + }, + selectAmount: { + id: "XrliJg", + defaultMessage: "Select amount to capture:", + description: "label for amount selection", + }, + optionOrderTotal: { + id: "tS2K/N", + defaultMessage: "Order total", + description: "radio option for capturing order total", + }, + optionOrderTotalHint: { + id: "v8e93p", + defaultMessage: "Matches what customer owes", + description: "hint for order total option", + }, + optionCustom: { + id: "IU1lif", + defaultMessage: "Custom amount", + description: "radio option for custom capture amount", + }, + customAmountMax: { + id: "Mm/Stj", + defaultMessage: "Max: {amount}", + description: "hint showing maximum allowed custom amount", + }, + captureButton: { + id: "bRXgSC", + defaultMessage: "Capture {amount}", + description: "capture button with amount", + }, + warningPartialAuthorization: { + id: "8JEG80", + defaultMessage: + "The remaining authorization doesn't cover the balance. {shortfall} will need a separate payment.", + description: "warning when authorized is less than total", + }, + errorNoAuthorization: { + id: "SnV3LR", + defaultMessage: + "No payment has been authorized for this order. The full amount of {amount} cannot be captured.", + description: "error when no authorization exists", + }, + outcomeMessage: { + id: "HSYM17", + defaultMessage: "This will result in {status} order", + description: "outcome prediction showing resulting order status after capture", + }, + statusFullyCapturedPill: { + id: "G9y5Ze", + defaultMessage: "Fully captured", + description: "pill status for fully captured outcome", + }, + statusPartiallyCapturedPill: { + id: "BJRu4V", + defaultMessage: "Partially captured", + description: "pill status for partially captured outcome", + }, + statusOvercapturedPill: { + id: "u7ShY+", + defaultMessage: "Overcaptured", + description: "pill status for overcaptured outcome", + }, +}); diff --git a/src/orders/components/OrderCaptureDialog/useCaptureState.test.ts b/src/orders/components/OrderCaptureDialog/useCaptureState.test.ts new file mode 100644 index 00000000000..8f27b5dab22 --- /dev/null +++ b/src/orders/components/OrderCaptureDialog/useCaptureState.test.ts @@ -0,0 +1,304 @@ +import { IMoney } from "@dashboard/utils/intl"; +import { renderHook } from "@testing-library/react-hooks"; + +import { CaptureStateInput, useCaptureState } from "./useCaptureState"; + +const createMoney = (amount: number, currency = "USD"): IMoney => ({ + amount, + currency, +}); + +describe("useCaptureState", () => { + describe("basic scenarios", () => { + it("should calculate state for fully authorized order with no charges", () => { + // Arrange + const input: CaptureStateInput = { + orderTotal: createMoney(100), + authorizedAmount: createMoney(100), + }; + + // Act + const { result } = renderHook(() => useCaptureState(input)); + + // Assert + expect(result.current).toEqual({ + availableToCapture: 100, + alreadyCharged: 0, + remainingToPay: 100, + status: "full", + maxCapturable: 100, + shortfall: 0, + canCaptureOrderTotal: true, + orderTotalCaptured: 0, + }); + }); + + it("should calculate state for partially authorized order", () => { + // Arrange + const input: CaptureStateInput = { + orderTotal: createMoney(100), + authorizedAmount: createMoney(60), + }; + + // Act + const { result } = renderHook(() => useCaptureState(input)); + + // Assert + expect(result.current).toEqual({ + availableToCapture: 60, + alreadyCharged: 0, + remainingToPay: 100, + status: "partial", + maxCapturable: 60, + shortfall: 40, + canCaptureOrderTotal: false, + orderTotalCaptured: 0, + }); + }); + + it("should calculate state for order with no authorization", () => { + // Arrange + const input: CaptureStateInput = { + orderTotal: createMoney(100), + authorizedAmount: createMoney(0), + }; + + // Act + const { result } = renderHook(() => useCaptureState(input)); + + // Assert + expect(result.current).toEqual({ + availableToCapture: 0, + alreadyCharged: 0, + remainingToPay: 100, + status: "none", + maxCapturable: 0, + shortfall: 100, + canCaptureOrderTotal: false, + orderTotalCaptured: 0, + }); + }); + }); + + describe("with charged amounts", () => { + it("should calculate state for partially charged order", () => { + // Arrange + const input: CaptureStateInput = { + orderTotal: createMoney(100), + authorizedAmount: createMoney(80), + chargedAmount: createMoney(20), + }; + + // Act + const { result } = renderHook(() => useCaptureState(input)); + + // Assert + expect(result.current).toEqual({ + availableToCapture: 80, + alreadyCharged: 20, + remainingToPay: 80, + status: "full", + maxCapturable: 80, + shortfall: 0, + canCaptureOrderTotal: true, + orderTotalCaptured: 20, + }); + }); + + it("should calculate state for fully charged order", () => { + // Arrange + const input: CaptureStateInput = { + orderTotal: createMoney(100), + authorizedAmount: createMoney(0), + chargedAmount: createMoney(100), + }; + + // Act + const { result } = renderHook(() => useCaptureState(input)); + + // Assert + expect(result.current).toEqual({ + availableToCapture: 0, + alreadyCharged: 100, + remainingToPay: 0, + status: "charged", + maxCapturable: 0, + shortfall: 0, + canCaptureOrderTotal: false, + orderTotalCaptured: 100, + }); + }); + + it("should calculate state when charged amount equals order total", () => { + // Arrange + const input: CaptureStateInput = { + orderTotal: createMoney(100), + authorizedAmount: createMoney(50), + chargedAmount: createMoney(100), + }; + + // Act + const { result } = renderHook(() => useCaptureState(input)); + + // Assert + expect(result.current).toEqual({ + availableToCapture: 50, + alreadyCharged: 100, + remainingToPay: 0, + status: "charged", + maxCapturable: 50, + shortfall: -50, + canCaptureOrderTotal: false, + orderTotalCaptured: 100, + }); + }); + }); + + describe("with order balance (multi-transaction)", () => { + it("should use order balance when provided (negative balance = customer owes)", () => { + // Arrange + const input: CaptureStateInput = { + orderTotal: createMoney(100), + authorizedAmount: createMoney(40), + chargedAmount: createMoney(20), + orderBalance: createMoney(-30), // Customer owes 30 + }; + + // Act + const { result } = renderHook(() => useCaptureState(input)); + + // Assert + expect(result.current).toEqual({ + availableToCapture: 40, + alreadyCharged: 20, + remainingToPay: 30, // From orderBalance + status: "full", + maxCapturable: 40, + shortfall: -10, + canCaptureOrderTotal: true, + orderTotalCaptured: 70, // 100 - 30 + }); + }); + + it("should handle zero order balance (fully paid)", () => { + // Arrange + const input: CaptureStateInput = { + orderTotal: createMoney(100), + authorizedAmount: createMoney(50), + chargedAmount: createMoney(80), + orderBalance: createMoney(0), + }; + + // Act + const { result } = renderHook(() => useCaptureState(input)); + + // Assert + expect(result.current).toEqual({ + availableToCapture: 50, + alreadyCharged: 80, + remainingToPay: 0, + status: "charged", + maxCapturable: 50, + shortfall: -50, + canCaptureOrderTotal: false, + orderTotalCaptured: 100, + }); + }); + + it("should handle positive order balance (overpaid)", () => { + // Arrange + const input: CaptureStateInput = { + orderTotal: createMoney(100), + authorizedAmount: createMoney(20), + chargedAmount: createMoney(120), + orderBalance: createMoney(20), // Overpaid by 20 + }; + + // Act + const { result } = renderHook(() => useCaptureState(input)); + + // Assert + expect(result.current).toEqual({ + availableToCapture: 20, + alreadyCharged: 120, + remainingToPay: 0, // Math.max(0, -20) = 0 + status: "charged", + maxCapturable: 20, + shortfall: -20, + canCaptureOrderTotal: false, + orderTotalCaptured: 100, + }); + }); + }); + + describe("edge cases", () => { + it("should handle zero order total", () => { + // Arrange + const input: CaptureStateInput = { + orderTotal: createMoney(0), + authorizedAmount: createMoney(0), + }; + + // Act + const { result } = renderHook(() => useCaptureState(input)); + + // Assert + expect(result.current).toEqual({ + availableToCapture: 0, + alreadyCharged: 0, + remainingToPay: 0, + status: "charged", + maxCapturable: 0, + shortfall: 0, + canCaptureOrderTotal: false, + orderTotalCaptured: 0, + }); + }); + + it("should handle over-authorization", () => { + // Arrange + const input: CaptureStateInput = { + orderTotal: createMoney(100), + authorizedAmount: createMoney(150), + }; + + // Act + const { result } = renderHook(() => useCaptureState(input)); + + // Assert + expect(result.current).toEqual({ + availableToCapture: 150, + alreadyCharged: 0, + remainingToPay: 100, + status: "full", + maxCapturable: 150, + shortfall: -50, + canCaptureOrderTotal: true, + orderTotalCaptured: 0, + }); + }); + + it("should handle negative authorized amount", () => { + // Arrange + const input: CaptureStateInput = { + orderTotal: createMoney(100), + authorizedAmount: createMoney(-10), + }; + + // Act + const { result } = renderHook(() => useCaptureState(input)); + + // Assert + expect(result.current).toEqual({ + availableToCapture: -10, + alreadyCharged: 0, + remainingToPay: 100, + status: "none", + maxCapturable: 0, // Math.max(0, -10) + shortfall: 110, + canCaptureOrderTotal: false, + orderTotalCaptured: 0, + }); + }); + }); +}); diff --git a/src/orders/components/OrderCaptureDialog/useCaptureState.ts b/src/orders/components/OrderCaptureDialog/useCaptureState.ts new file mode 100644 index 00000000000..3566b2009dd --- /dev/null +++ b/src/orders/components/OrderCaptureDialog/useCaptureState.ts @@ -0,0 +1,81 @@ +import { IMoney } from "@dashboard/utils/intl"; +import { useMemo } from "react"; + +export type AuthorizationStatus = "full" | "partial" | "none" | "charged"; + +export interface CaptureStateInput { + orderTotal: IMoney; + authorizedAmount: IMoney; + chargedAmount?: IMoney; + orderBalance?: IMoney; +} + +export interface CaptureState { + availableToCapture: number; + alreadyCharged: number; + remainingToPay: number; + status: AuthorizationStatus; + maxCapturable: number; + shortfall: number; + canCaptureOrderTotal: boolean; + orderTotalCaptured: number; +} + +export const useCaptureState = ({ + orderTotal, + authorizedAmount, + chargedAmount, + orderBalance, +}: CaptureStateInput): CaptureState => { + return useMemo(() => { + const totalAmount = orderTotal.amount; + const authAmount = authorizedAmount.amount; + const alreadyCharged = chargedAmount?.amount ?? 0; + + // With bucket model: authorizedAmount = what's available to capture + const availableToCapture = authAmount; + + // Calculate what customer still owes: + // - If orderBalance provided (multi-transaction): use it (negative balance = owes money) + // - Otherwise: simple calculation from order total minus charged + const remainingToPay = orderBalance + ? Math.max(0, -orderBalance.amount) + : totalAmount - alreadyCharged; + + // Order-wide captured amount (for display in Order section) + const orderTotalCaptured = totalAmount - remainingToPay; + + // Determine authorization status + const getAuthorizationStatus = (): AuthorizationStatus => { + if (remainingToPay <= 0) { + return "charged"; + } + + if (availableToCapture <= 0) { + return "none"; + } + + if (availableToCapture >= remainingToPay) { + return "full"; + } + + return "partial"; + }; + + const status = getAuthorizationStatus(); + const maxCapturable = Math.max(0, availableToCapture); + const shortfall = remainingToPay - availableToCapture; + const canCaptureOrderTotal = availableToCapture >= remainingToPay && remainingToPay > 0; + + return { + availableToCapture, + alreadyCharged, + remainingToPay, + status, + maxCapturable, + shortfall, + canCaptureOrderTotal, + orderTotalCaptured, + }; + }, [orderTotal, authorizedAmount, chargedAmount, orderBalance]); +}; diff --git a/src/orders/components/OrderSummary/LegacyPaymentsApiButtons.tsx b/src/orders/components/OrderSummary/LegacyPaymentsApiButtons.tsx index 120fe6eff2a..ddd3575dba7 100644 --- a/src/orders/components/OrderSummary/LegacyPaymentsApiButtons.tsx +++ b/src/orders/components/OrderSummary/LegacyPaymentsApiButtons.tsx @@ -1,5 +1,6 @@ import { OrderDetailsFragment, OrderStatus } from "@dashboard/graphql"; import { Box, Button } from "@saleor/macaw-ui-next"; +import { ReactNode } from "react"; import { useIntl } from "react-intl"; import { transactionActionMessages } from "../OrderTransaction/messages"; @@ -26,7 +27,7 @@ export const LegacyPaymentsApiButtons = ({ onLegacyPaymentsApiCapture, onLegacyPaymentsApiRefund, onLegacyPaymentsApiVoid, -}: Props) => { +}: Props): ReactNode => { const intl = useIntl(); const showButtons = diff --git a/src/orders/urls.ts b/src/orders/urls.ts index a0ebbf59dc6..fe1755ee71c 100644 --- a/src/orders/urls.ts +++ b/src/orders/urls.ts @@ -209,6 +209,7 @@ export type OrderUrlDialog = | "mark-paid" | "void" | "transaction-action" + | "transaction-charge-action" | "invoice-send" | "add-manual-transaction" | "view-order-line-metadata" @@ -216,7 +217,7 @@ export type OrderUrlDialog = | "view-fulfillment-metadata"; interface TransactionAction { - action: "transaction-action"; + action: "transaction-action" | "transaction-charge-action"; id: string; type: TransactionActionEnum; } diff --git a/src/orders/views/OrderDetails/OrderDetails.tsx b/src/orders/views/OrderDetails/OrderDetails.tsx index 5c5b43609df..cdfb1b2f3e5 100644 --- a/src/orders/views/OrderDetails/OrderDetails.tsx +++ b/src/orders/views/OrderDetails/OrderDetails.tsx @@ -138,7 +138,12 @@ const OrderDetails = ({ id, params }: OrderDetailsProps) => { } }} onInvoiceSend={orderMessages.handleInvoiceSend} - onTransactionActionSend={orderMessages.handleTransactionAction} + onTransactionActionSend={async data => { + await apolloClient.refetchQueries({ + include: [OrderDetailsWithMetadataDocument], + }); + orderMessages.handleTransactionAction(data); + }} onManualTransactionAdded={async data => { await apolloClient.refetchQueries({ include: [OrderDetailsWithMetadataDocument], diff --git a/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx b/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx index 0315f611185..e7d7d26989b 100644 --- a/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx +++ b/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx @@ -15,6 +15,7 @@ import { OrderTransactionRequestActionMutationVariables, OrderUpdateMutation, OrderUpdateMutationVariables, + TransactionActionEnum, useCustomerAddressesQuery, useWarehouseListQuery, } from "@dashboard/graphql"; @@ -46,18 +47,18 @@ import { OpenModalFunction, } from "@dashboard/utils/handlers/dialogActionHandlers"; import { mapEdgesToItems } from "@dashboard/utils/maps"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useIntl } from "react-intl"; import { customerUrl } from "../../../../customers/urls"; import { productUrl } from "../../../../products/urls"; import OrderAddressFields from "../../../components/OrderAddressFields/OrderAddressFields"; import OrderCancelDialog from "../../../components/OrderCancelDialog"; +import { OrderCaptureDialog } from "../../../components/OrderCaptureDialog/OrderCaptureDialog"; import OrderDetailsPage from "../../../components/OrderDetailsPage/OrderDetailsPage"; import OrderFulfillmentCancelDialog from "../../../components/OrderFulfillmentCancelDialog"; import OrderFulfillmentTrackingDialog from "../../../components/OrderFulfillmentTrackingDialog"; import OrderMarkAsPaidDialog from "../../../components/OrderMarkAsPaidDialog/OrderMarkAsPaidDialog"; -import OrderPaymentDialog from "../../../components/OrderPaymentDialog"; import OrderPaymentVoidDialog from "../../../components/OrderPaymentVoidDialog"; import { orderFulfillUrl, @@ -174,6 +175,11 @@ export const OrderNormalDetails = ({ const errors = orderUpdate.opts.data?.orderUpdate.errors || []; + const selectedTransaction = useMemo( + () => order?.transactions?.find(t => t.id === params.id), + [order?.transactions, params.id], + ); + const hasOrderFulfillmentsFulfilled = order?.fulfillments.some( fulfillment => fulfillment.status === FulfillmentStatus.FULFILLED, ); @@ -231,11 +237,19 @@ export const OrderNormalDetails = ({ onOrderShowMetadata={() => openModal("view-order-metadata")} onFulfillmentShowMetadata={id => openModal("view-fulfillment-metadata", { id })} onTransactionAction={(id, action) => - openModal("transaction-action", { - type: action, - id, - action: "transaction-action", - }) + openModal( + action === TransactionActionEnum.CHARGE + ? "transaction-charge-action" + : "transaction-action", + { + type: action, + id, + action: + action === TransactionActionEnum.CHARGE + ? "transaction-charge-action" + : "transaction-action", + }, + ) } onOrderFulfill={() => navigate(orderFulfillUrl(id))} onFulfillmentApprove={fulfillmentId => @@ -303,6 +317,29 @@ export const OrderNormalDetails = ({ }) } /> + {/* Transaction Capture Dialog - for CHARGE action */} + {params.action === "transaction-charge-action" && order && selectedTransaction && ( + + orderTransactionAction + .mutate({ + action: params.type, + transactionId: params.id, + amount, + }) + .finally(() => closeModal()) + } + /> + )} + {/* Transaction Action Dialog - for other actions like CANCEL */} orderVoid.mutate({ id })} /> - - orderPaymentCapture.mutate({ - ...variables, - id, - }) - } - /> + {params.action === "capture" && order && ( + + orderPaymentCapture.mutate({ + amount, + id, + }) + } + /> + )} order?.transactions?.find(t => t.id === params.id), + [order?.transactions, params.id], + ); const hasOrderFulfillmentsFulFilled = order?.fulfillments.some( fulfillment => fulfillment.status === FulfillmentStatus.FULFILLED, @@ -213,11 +222,19 @@ export const OrderUnconfirmedDetails = ({ order={order} shop={shop} onTransactionAction={(id, action) => - openModal("transaction-action", { - type: action, - id, - action: "transaction-action", - }) + openModal( + action === TransactionActionEnum.CHARGE + ? "transaction-charge-action" + : "transaction-action", + { + type: action, + id, + action: + action === TransactionActionEnum.CHARGE + ? "transaction-charge-action" + : "transaction-action", + }, + ) } onOrderLineAdd={() => openModal("add-order-line")} onOrderLineChange={(id, data) => @@ -366,6 +383,29 @@ export const OrderUnconfirmedDetails = ({ transactionReference={transactionReference} handleTransactionReference={({ target }) => setTransactionReference(target.value)} /> + {/* Transaction Capture Dialog - for CHARGE action */} + {params.action === "transaction-charge-action" && ( + + orderTransactionAction + .mutate({ + action: params.type, + transactionId: params.id, + amount, + }) + .finally(() => closeModal()) + } + /> + )} + {/* Transaction Action Dialog - for other actions like CANCEL */} orderVoid.mutate({ id })} /> - - orderPaymentCapture.mutate({ - ...variables, - id, - }) - } - /> + {params.action === "capture" && ( + + orderPaymentCapture.mutate({ + amount, + id, + }) + } + /> + )}