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,
+ })
+ }
+ />
+ )}