diff --git a/apps/admin/public/assets/locales/en-US/order.json b/apps/admin/public/assets/locales/en-US/order.json
index c0809ba..7a90de3 100644
--- a/apps/admin/public/assets/locales/en-US/order.json
+++ b/apps/admin/public/assets/locales/en-US/order.json
@@ -16,10 +16,11 @@
"0": "Status",
"1": "Pending",
"2": "Paid",
- "3": "Cancelled",
- "4": "Closed",
- "5": "Completed",
- "6": "Refunded"
+ "3": "Closed",
+ "4": "Failed",
+ "5": "Finished",
+ "6": "Claimed",
+ "7": "Refunded"
},
"subscribe": "Subscribe",
"subscribePrice": "Subscription Price",
@@ -33,5 +34,8 @@
"4": "Recharge"
},
"updateTime": "Update Time",
- "user": "User"
+ "user": "User",
+ "refundFailed": "Refund failed. Please try again.",
+ "statusClaimedBlocked": "Claimed and refunded states must use the dedicated processing flow.",
+ "statusUpdateFailed": "Status update failed. Please try again."
}
diff --git a/apps/admin/public/assets/locales/zh-CN/order.json b/apps/admin/public/assets/locales/zh-CN/order.json
index 20dc641..fb5a6ac 100644
--- a/apps/admin/public/assets/locales/zh-CN/order.json
+++ b/apps/admin/public/assets/locales/zh-CN/order.json
@@ -16,10 +16,11 @@
"0": "状态",
"1": "待支付",
"2": "已支付",
- "3": "已取消",
- "4": "已关闭",
+ "3": "已关闭",
+ "4": "失败",
"5": "已完成",
- "6": "已退费"
+ "6": "处理中",
+ "7": "已退费"
},
"subscribe": "订阅",
"subscribePrice": "订阅价格",
@@ -33,5 +34,8 @@
"4": "充值"
},
"updateTime": "更新时间",
- "user": "用户"
+ "user": "用户",
+ "refundFailed": "退费失败,请重试。",
+ "statusClaimedBlocked": "处理中和已退费状态必须走专用流程,不能通过通用改状态提交。",
+ "statusUpdateFailed": "状态更新失败,请重试。"
}
diff --git a/apps/admin/src/sections/order/index.test.tsx b/apps/admin/src/sections/order/index.test.tsx
new file mode 100644
index 0000000..27e68db
--- /dev/null
+++ b/apps/admin/src/sections/order/index.test.tsx
@@ -0,0 +1,466 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import {
+ activateOrder,
+ getOrderList,
+ refundOrder,
+ updateOrderStatus,
+} from "@workspace/ui/services/admin/order";
+import type { AxiosResponse } from "axios";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import Order, { canManuallyUpdateOrderStatus, canRefundOrderStatus } from ".";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (_key: string, fallback: string): string => fallback,
+ }),
+}));
+
+vi.mock("@tanstack/react-router", () => ({
+ useSearch: () => ({}),
+}));
+
+vi.mock("@/stores/global", () => ({
+ useGlobalStore: (
+ selector?: (state: {
+ user: { is_admin: boolean };
+ common: { currency: { currency_symbol: string } };
+ }) => unknown
+ ) => {
+ const state = {
+ user: {
+ is_admin: true,
+ },
+ common: {
+ currency: {
+ currency_symbol: "$",
+ },
+ },
+ };
+
+ return typeof selector === "function" ? selector(state) : state;
+ },
+}));
+
+vi.mock("@/stores/subscribe", () => ({
+ useSubscribe: () => ({
+ subscribes: [
+ {
+ id: 9,
+ name: "Pro Plan",
+ },
+ ],
+ getSubscribeName: (id?: number) => (id === 9 ? "Pro Plan" : "--"),
+ }),
+}));
+
+vi.mock("@/utils/common", () => ({
+ formatDate: (timestamp: number) => `date-${timestamp}`,
+}));
+
+vi.mock("../user/user-detail", () => ({
+ UserDetail: ({ id }: { id: number }) => User #{id},
+}));
+
+vi.mock("@/components/display", () => ({
+ Display: ({ value }: { value: number }) => {value},
+}));
+
+const toastSuccess = vi.fn();
+const toastError = vi.fn();
+
+vi.mock("sonner", () => ({
+ toast: {
+ success: (...args: unknown[]) => toastSuccess(...args),
+ error: (...args: unknown[]) => toastError(...args),
+ },
+}));
+
+vi.mock("@workspace/ui/services/admin/order", () => ({
+ activateOrder: vi.fn(),
+ getOrderList: vi.fn(),
+ refundOrder: vi.fn(),
+ updateOrderStatus: vi.fn(),
+}));
+
+vi.mock("@workspace/ui/components/alert-dialog", () => ({
+ AlertDialog: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ AlertDialogTrigger: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogHeader: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogTitle: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogDescription: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogFooter: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AlertDialogCancel: ({
+ children,
+ ...props
+ }: React.ComponentProps<"button">) => ,
+ AlertDialogAction: ({
+ children,
+ ...props
+ }: React.ComponentProps<"button">) => ,
+}));
+
+vi.mock("@workspace/ui/composed/combobox", () => ({
+ Combobox: ({
+ options = [],
+ value,
+ onChange,
+ placeholder,
+ }: {
+ options?: Array<{ label: string; value: number | string }>;
+ value?: number | string;
+ onChange: (value: number) => void;
+ placeholder?: string;
+ }) => (
+
+ ),
+}));
+
+vi.mock("@workspace/ui/composed/pro-table/pro-table", async () => {
+ const React = await import("react");
+
+ type Column = {
+ accessorKey?: string;
+ cell?: (props: {
+ row: {
+ original: Record;
+ getValue: (key: string) => unknown;
+ };
+ }) => React.ReactNode;
+ };
+
+ return {
+ ProTable: ({
+ columns,
+ request,
+ actions,
+ }: {
+ columns: Column[];
+ request: (
+ pagination: { page: number; size: number },
+ filter: Record
+ ) => Promise<{ list: Record[]; total: number }>;
+ actions?: {
+ render?: (row: Record) => React.ReactNode[];
+ };
+ }) => {
+ const [state, setState] = React.useState<{
+ data: Record[];
+ error: boolean;
+ loading: boolean;
+ }>({
+ data: [],
+ error: false,
+ loading: true,
+ });
+
+ React.useEffect(() => {
+ let cancelled = false;
+ request({ page: 1, size: 200 }, {})
+ .then((response) => {
+ if (cancelled) return;
+ setState({
+ data: response.list,
+ error: false,
+ loading: false,
+ });
+ })
+ .catch(() => {
+ if (cancelled) return;
+ setState({
+ data: [],
+ error: true,
+ loading: false,
+ });
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [request]);
+
+ if (state.loading) {
+ return ;
+ }
+
+ if (state.error) {
+ return Failed to load orders
;
+ }
+
+ if (state.data.length === 0) {
+ return No orders
;
+ }
+
+ return (
+
+ {state.data.map((row) => (
+
+ {columns.map((column, index) => {
+ const value = column.accessorKey
+ ? row[column.accessorKey]
+ : undefined;
+ const rowApi = {
+ original: row,
+ getValue: (key: string) => row[key],
+ };
+
+ return (
+
+ {column.cell
+ ? column.cell({ row: rowApi })
+ : typeof value === "object"
+ ? JSON.stringify(value)
+ : String(value ?? "")}
+
+ );
+ })}
+ {actions?.render?.(row)}
+
+ ))}
+
+ );
+ },
+ };
+});
+
+const mockedGetOrderList = vi.mocked(getOrderList);
+const mockedRefundOrder = vi.mocked(refundOrder);
+const mockedUpdateOrderStatus = vi.mocked(updateOrderStatus);
+const mockedActivateOrder = vi.mocked(activateOrder);
+
+function createOrderListResponse(
+ list: API.Order[]
+): AxiosResponse {
+ return {
+ data: {
+ code: 200,
+ data: {
+ list,
+ total: list.length,
+ },
+ },
+ status: 200,
+ statusText: "OK",
+ headers: {},
+ config: {
+ headers: {} as AxiosResponse["config"]["headers"],
+ },
+ };
+}
+
+function createSuccessResponse(): AxiosResponse<
+ API.Response & { data?: unknown }
+> {
+ return {
+ data: {
+ code: 200,
+ },
+ status: 200,
+ statusText: "OK",
+ headers: {},
+ config: {
+ headers: {} as AxiosResponse["config"]["headers"],
+ },
+ };
+}
+
+function createOrder(status: number): API.Order {
+ return {
+ id: status,
+ amount: 1999,
+ coupon_discount: 0,
+ discount: 0,
+ fee_amount: 0,
+ order_no: `ORDER-${status}`,
+ payment: {
+ name: "Stripe",
+ platform: "stripe",
+ },
+ price: 1999,
+ quantity: 1,
+ status,
+ status_name:
+ status === 6 ? "claimed" : status === 7 ? "refunded" : "pending",
+ subscribe_id: 9,
+ trade_no: `TRADE-${status}`,
+ type: 1,
+ updated_at: 1_716_800_000,
+ user_id: 88,
+ } as API.Order;
+}
+
+function renderOrderPage() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ });
+
+ return render(
+
+
+
+ );
+}
+
+beforeEach(() => {
+ localStorage.setItem("timezone", "UTC");
+});
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+describe("Order", () => {
+ it("uses the dedicated refund endpoint for refundable orders", async () => {
+ mockedGetOrderList.mockResolvedValue(
+ createOrderListResponse([createOrder(2)])
+ );
+ mockedRefundOrder.mockResolvedValue(createSuccessResponse());
+
+ renderOrderPage();
+
+ expect(await screen.findByText("ORDER-2")).not.toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Confirm refund" }));
+
+ await waitFor(() =>
+ expect(mockedRefundOrder).toHaveBeenCalledWith(
+ { id: 2 },
+ { skipErrorHandler: true }
+ )
+ );
+ expect(toastSuccess).toHaveBeenCalledWith("Refund completed.");
+ });
+
+ it("blocks status 6 and 7 from the generic status editor", async () => {
+ mockedGetOrderList.mockResolvedValue(
+ createOrderListResponse([createOrder(1)])
+ );
+ mockedUpdateOrderStatus.mockResolvedValue(createSuccessResponse());
+
+ renderOrderPage();
+
+ const statusSelect = await screen.findByLabelText("Status");
+ const optionValues = Array.from(
+ statusSelect.querySelectorAll("option")
+ ).map((option) => option.textContent);
+
+ expect(optionValues).not.toContain("Claimed");
+ expect(optionValues).not.toContain("Refunded");
+ expect(canManuallyUpdateOrderStatus(6)).toBe(false);
+ expect(canManuallyUpdateOrderStatus(7)).toBe(false);
+
+ fireEvent.change(statusSelect, { target: { value: "2" } });
+
+ await waitFor(() =>
+ expect(mockedUpdateOrderStatus).toHaveBeenCalledWith(
+ { id: 1, status: 2 },
+ { skipErrorHandler: true }
+ )
+ );
+ });
+
+ it("renders loading state while orders are being fetched", () => {
+ mockedGetOrderList.mockReturnValue(
+ new Promise(() => {
+ // Keep the request pending so the loading UI remains visible.
+ }) as ReturnType
+ );
+
+ renderOrderPage();
+
+ expect(screen.getByRole("status", { name: "Loading data" })).not.toBeNull();
+ });
+
+ it("renders the empty state when there are no orders", async () => {
+ mockedGetOrderList.mockResolvedValue(createOrderListResponse([]));
+
+ renderOrderPage();
+
+ expect(await screen.findByText("No orders")).not.toBeNull();
+ });
+
+ it("shows an error toast when refund fails", async () => {
+ mockedGetOrderList.mockResolvedValue(
+ createOrderListResponse([createOrder(5)])
+ );
+ mockedRefundOrder.mockRejectedValue(new Error("refund failed"));
+
+ renderOrderPage();
+
+ expect(await screen.findByText("ORDER-5")).not.toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Confirm refund" }));
+
+ await waitFor(() =>
+ expect(toastError).toHaveBeenCalledWith(
+ "Refund failed. Please try again."
+ )
+ );
+ });
+
+ it("renders the error state when the order list request fails", async () => {
+ mockedGetOrderList.mockRejectedValue(new Error("network failed"));
+
+ renderOrderPage();
+
+ expect(await screen.findByText("Failed to load orders")).not.toBeNull();
+ });
+
+ it("keeps the order actions visible on a narrow viewport", async () => {
+ window.innerWidth = 375;
+ window.dispatchEvent(new Event("resize"));
+ mockedGetOrderList.mockResolvedValue(
+ createOrderListResponse([createOrder(2), createOrder(6), createOrder(7)])
+ );
+
+ renderOrderPage();
+
+ expect(await screen.findByText("ORDER-2")).not.toBeNull();
+ expect(screen.getByText("Claimed")).not.toBeNull();
+ expect(screen.getAllByText("Refund").length).toBe(1);
+ expect(canRefundOrderStatus(2)).toBe(true);
+ expect(canRefundOrderStatus(6)).toBe(false);
+ expect(canRefundOrderStatus(7)).toBe(false);
+ expect(mockedActivateOrder).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/admin/src/sections/order/index.tsx b/apps/admin/src/sections/order/index.tsx
index e9d1e28..45fc10b 100644
--- a/apps/admin/src/sections/order/index.tsx
+++ b/apps/admin/src/sections/order/index.tsx
@@ -40,7 +40,31 @@ import { useSubscribe } from "@/stores/subscribe";
import { formatDate } from "@/utils/common";
import { UserDetail } from "../user/user-detail";
-const REFUNDED_ORDER_STATUS = 6;
+const ORDER_STATUS_PENDING = 1;
+const ORDER_STATUS_PAID = 2;
+const ORDER_STATUS_CLOSED = 3;
+const ORDER_STATUS_FAILED = 4;
+const ORDER_STATUS_FINISHED = 5;
+const ORDER_STATUS_CLAIMED = 6;
+const ORDER_STATUS_REFUNDED = 7;
+
+const MANUALLY_EDITABLE_ORDER_STATUSES = [
+ ORDER_STATUS_PENDING,
+ ORDER_STATUS_PAID,
+ ORDER_STATUS_CLOSED,
+ ORDER_STATUS_FAILED,
+ ORDER_STATUS_FINISHED,
+] as const;
+
+export function canManuallyUpdateOrderStatus(status: number) {
+ return MANUALLY_EDITABLE_ORDER_STATUSES.includes(
+ status as (typeof MANUALLY_EDITABLE_ORDER_STATUSES)[number]
+ );
+}
+
+export function canRefundOrderStatus(status: number) {
+ return [ORDER_STATUS_PAID, ORDER_STATUS_FINISHED].includes(status);
+}
export default function Order() {
const { t } = useTranslation("order");
@@ -56,30 +80,46 @@ export default function Order() {
subscribe_id: sp.subscribe_id || undefined,
};
- const statusOptions = [
+ const displayStatusOptions = [
{
- value: 1,
+ value: ORDER_STATUS_PENDING,
label: t("status.1", "Pending"),
className: "bg-orange-500",
},
- { value: 2, label: t("status.2", "Paid"), className: "bg-green-500" },
{
- value: 3,
- label: t("status.3", "Cancelled"),
- className: "bg-gray-500",
- },
- { value: 4, label: t("status.4", "Closed"), className: "bg-red-500" },
- {
- value: 5,
- label: t("status.5", "Completed"),
+ value: ORDER_STATUS_PAID,
+ label: t("status.2", "Paid"),
className: "bg-green-500",
},
{
- value: REFUNDED_ORDER_STATUS,
- label: t("status.6", "Refunded"),
+ value: ORDER_STATUS_CLOSED,
+ label: t("status.3", "Closed"),
+ className: "bg-gray-500",
+ },
+ {
+ value: ORDER_STATUS_FAILED,
+ label: t("status.4", "Failed"),
+ className: "bg-red-500",
+ },
+ {
+ value: ORDER_STATUS_FINISHED,
+ label: t("status.5", "Finished"),
+ className: "bg-green-500",
+ },
+ {
+ value: ORDER_STATUS_CLAIMED,
+ label: t("status.6", "Claimed"),
+ className: "bg-amber-500",
+ },
+ {
+ value: ORDER_STATUS_REFUNDED,
+ label: t("status.7", "Refunded"),
className: "bg-red-500",
},
];
+ const editableStatusOptions = displayStatusOptions.filter((option) =>
+ canManuallyUpdateOrderStatus(option.value)
+ );
const typeOptions = [
{ value: 1, label: t("type.1", "New Purchase") },
@@ -95,29 +135,36 @@ export default function Order() {
const refundMutation = useMutation({
mutationFn: async (order: API.Order) => {
- await refundOrder({ id: order.id });
+ await refundOrder(
+ { id: order.id },
+ {
+ skipErrorHandler: true,
+ }
+ );
},
onSuccess: () => {
toast.success(t("refundSuccess", "Refund completed."));
setConfirmingOrderId(null);
ref.current?.refresh();
},
- onError: (error) => {
- console.error("Refund order failed", error);
+ onError: () => {
+ toast.error(t("refundFailed", "Refund failed. Please try again."));
setConfirmingOrderId(null);
},
});
const isRefundedOrder = (order: API.Order) =>
- order.status === REFUNDED_ORDER_STATUS ||
- order.status_name === t("status.6", "Refunded") ||
+ order.status === ORDER_STATUS_REFUNDED ||
+ order.status_name === t("status.7", "Refunded") ||
order.status_name?.toLowerCase() === "refunded";
const canRefundOrder = (order: API.Order) =>
- canRefundOrders && !isRefundedOrder(order);
+ canRefundOrders &&
+ canRefundOrderStatus(order.status) &&
+ !isRefundedOrder(order);
return (
-
+ >
action={ref}
actions={{
render: (order) => {
@@ -173,7 +220,11 @@ export default function Order() {
onClick={async (event) => {
event.preventDefault();
if (isPending) return;
- await refundMutation.mutateAsync(order);
+ try {
+ await refundMutation.mutateAsync(order);
+ } catch {
+ // Errors are surfaced through the mutation onError handler.
+ }
}}
>
{isPending
@@ -335,26 +386,55 @@ export default function Order() {
header: t("status.0", "Status"),
cell: ({ row }) => {
const order = row.original as API.Order;
- const option = statusOptions.find(
+ const option = displayStatusOptions.find(
(opt) => opt.value === order.status
);
- if ([1, 3, 4].includes(row.getValue("status"))) {
+ if (
+ [ORDER_STATUS_PENDING, ORDER_STATUS_CLOSED, ORDER_STATUS_FAILED]
+ .map(String)
+ .includes(String(row.getValue("status")))
+ ) {
return (
className={cn(option?.className)}
onChange={async (value) => {
- await updateOrderStatus({
- id: order.id,
- status: value,
- });
- ref.current?.refresh();
+ if (!canManuallyUpdateOrderStatus(value)) {
+ toast.error(
+ t(
+ "statusClaimedBlocked",
+ "Claimed and refunded states must use the dedicated processing flow."
+ )
+ );
+ return;
+ }
+ try {
+ await updateOrderStatus(
+ {
+ id: order.id,
+ status: value,
+ },
+ {
+ skipErrorHandler: true,
+ }
+ );
+ ref.current?.refresh();
+ } catch {
+ toast.error(
+ t(
+ "statusUpdateFailed",
+ "Status update failed. Please try again."
+ )
+ );
+ }
}}
- options={statusOptions}
+ options={editableStatusOptions}
placeholder={t("status.0", "Status")}
value={order.status}
/>
- {[1, 3].includes(order.status) && (
+ {[ORDER_STATUS_PENDING, ORDER_STATUS_CLOSED].includes(
+ order.status
+ ) && (