diff --git a/driver-pwa/frontend/src/api/driver.ts b/driver-pwa/frontend/src/api/driver.ts index 9babf3f..31ce636 100644 --- a/driver-pwa/frontend/src/api/driver.ts +++ b/driver-pwa/frontend/src/api/driver.ts @@ -104,8 +104,11 @@ export const getBalance = async () => BalanceSchema.parse(await api.get("driver/balance").json()); export const getBuckets = async () => BucketsSchema.parse(await api.get("driver/buckets").json()); -export const topup = async (bucket: string, amount: number, method: "qr" | "card") => - TopupSchema.parse(await api.post("driver/topup", { json: { bucket, amount, method } }).json()); +export const topup = async (bucket: string, amount: number, method: "qr" | "card", + live = false) => + TopupSchema.parse(await api.post("driver/topup", { + json: { bucket, amount, method, live }, + }).json()); export const getPaymentState = async (orderId: string) => PaymentStateSchema.parse(await api.get(`driver/payment/${orderId}`).json()); export const getPayments = async () => diff --git a/driver-pwa/frontend/src/pages/DebugPage.tsx b/driver-pwa/frontend/src/pages/DebugPage.tsx index 2d4b731..a70d52b 100644 --- a/driver-pwa/frontend/src/pages/DebugPage.tsx +++ b/driver-pwa/frontend/src/pages/DebugPage.tsx @@ -68,10 +68,30 @@ export function DebugPage() { )} + {impersonated && ( +
+

Боевая оплата

+

+ Выключено — пополнение открывает учебный экран, деньги не двигаются. + Включено — платёж уходит в банк по-настоящему: спишется с вашей карты, + чек уйдёт на телефон водителя, сумма зачислится ему в 1С. +

+ +
+ )} +

Сведения

+ {impersonated && ( + + )} diff --git a/driver-pwa/frontend/src/pages/TopupPage.test.tsx b/driver-pwa/frontend/src/pages/TopupPage.test.tsx index 2fdd690..464b284 100644 --- a/driver-pwa/frontend/src/pages/TopupPage.test.tsx +++ b/driver-pwa/frontend/src/pages/TopupPage.test.tsx @@ -5,7 +5,8 @@ import { MemoryRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; vi.mock("@/api/driver", () => ({ - topup: vi.fn(async () => ({ order_id: "o9", pay_url: "https://qr.nspk.ru/x", commission: 52, total: 1052 })), + topup: vi.fn(async () => ({ order_id: "o9", pay_url: "https://qr.nspk.ru/x", commission: 52, + total: 1052, mock: false })), getCommission: vi.fn(async () => ({ rate: 0.052 })), getBuckets: vi.fn(async () => ({ buckets: ["Долг аренда", "Долг по штрафам"], with_debt: ["Долг аренда"], @@ -34,7 +35,7 @@ describe("TopupPage", () => { expect(screen.getByTestId("amount").textContent).toBe("1 000 ₽"); expect(screen.getByTestId("total").textContent).toContain("1 052"); await userEvent.click(screen.getByRole("button", { name: /Оплатить/i })); - expect(driver.topup).toHaveBeenCalledWith("Долг аренда", 1000, "qr"); + expect(driver.topup).toHaveBeenCalledWith("Долг аренда", 1000, "qr", false); }); it("decimal key builds kopecks and they reach the backend", async () => { @@ -46,7 +47,7 @@ describe("TopupPage", () => { } expect(screen.getByTestId("amount").textContent).toBe("100,52 ₽"); await userEvent.click(screen.getByRole("button", { name: /Оплатить/i })); - expect(driver.topup).toHaveBeenCalledWith("Долг аренда", 100.52, "qr"); + expect(driver.topup).toHaveBeenCalledWith("Долг аренда", 100.52, "qr", false); }); it("lets the driver choose the debt category instead of hardcoding rent", async () => { @@ -57,6 +58,32 @@ describe("TopupPage", () => { await userEvent.click(screen.getByRole("button", { name: "0" })); await userEvent.click(screen.getByRole("button", { name: "0" })); await userEvent.click(screen.getByRole("button", { name: /Оплатить/i })); - expect(driver.topup).toHaveBeenCalledWith("Долг по штрафам", 500, "qr"); + expect(driver.topup).toHaveBeenCalledWith("Долг по штрафам", 500, "qr", false); + }); +}); + +describe("боевая оплата под просмотром", () => { + it("по умолчанию платёж учебный — флаг в запрос уходит выключенным", async () => { + const { useStaff } = await import("@/store/staff"); + useStaff.getState().clear(); + wrap(); + await userEvent.click(screen.getByRole("button", { name: "5" })); + await userEvent.click(screen.getByRole("button", { name: "0" })); + await userEvent.click(screen.getByRole("button", { name: "0" })); + await userEvent.click(screen.getByRole("button", { name: /Оплатить/i })); + expect(driver.topup).toHaveBeenLastCalledWith("Долг аренда", 500, "qr", false); + }); + + it("включённая боевая оплата предупреждает и уходит с флагом", async () => { + const { useStaff } = await import("@/store/staff"); + useStaff.getState().setLivePay(true); + wrap(); + expect(screen.getByText(/деньги спишутся по-настоящему/i)).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "5" })); + await userEvent.click(screen.getByRole("button", { name: "0" })); + await userEvent.click(screen.getByRole("button", { name: "0" })); + await userEvent.click(screen.getByRole("button", { name: /Оплатить/i })); + expect(driver.topup).toHaveBeenLastCalledWith("Долг аренда", 500, "qr", true); + useStaff.getState().clear(); }); }); diff --git a/driver-pwa/frontend/src/pages/TopupPage.tsx b/driver-pwa/frontend/src/pages/TopupPage.tsx index e2b2e85..b741099 100644 --- a/driver-pwa/frontend/src/pages/TopupPage.tsx +++ b/driver-pwa/frontend/src/pages/TopupPage.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import { apiErrorText, topup, getCommission, getBuckets } from "@/api/driver"; import { keypadReduce, parseAmount, withCommission } from "@/lib/amount"; import { formatMoney } from "@/lib/format"; +import { useStaff } from "@/store/staff"; import { Segment } from "@/components/Segment"; import { Spinner } from "@/components/Spinner"; @@ -14,6 +15,7 @@ const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", ",", "0", "back"]; export function TopupPage() { const nav = useNavigate(); + const livePay = useStaff((s) => s.livePay); const loc = useLocation(); const fromBalance = (loc.state as { bucket?: string } | null)?.bucket; // Список категорий приходит с сервера — там же он и проверяется. Хардкод @@ -40,7 +42,7 @@ export function TopupPage() { if (base <= 0 || !bucket || busy) return; setBusy(true); try { - const r = await topup(bucket, base, method as "qr" | "card"); + const r = await topup(bucket, base, method as "qr" | "card", livePay); // Учебный платёж (сотрудник смотрит от чужого лица): банка нет, платить // негде — сразу на экран выбора исхода, дальше всё как у водителя. if (r.mock) { @@ -113,6 +115,13 @@ export function TopupPage() {
+ {livePay && ( + // Предупреждение прямо над кнопкой: под чужим лицом ошибиться легко, а + // отыграть боевой платёж — это возврат в банке и правка в 1С. +

+ Боевая оплата: деньги спишутся по-настоящему и зачислятся водителю +

+ )} diff --git a/driver-pwa/frontend/src/store/staff.ts b/driver-pwa/frontend/src/store/staff.ts index debf6e0..eb2b1a2 100644 --- a/driver-pwa/frontend/src/store/staff.ts +++ b/driver-pwa/frontend/src/store/staff.ts @@ -12,7 +12,11 @@ import { persist } from "zustand/middleware"; interface StaffState { token: string | null; username: string | null; + /** Боевая оплата под просмотром: платёж уходит в банк и зачисляется водителю. + * Выключено по умолчанию — под чужим лицом легко забыть, кого пополняешь. */ + livePay: boolean; setStaff: (token: string, username: string) => void; + setLivePay: (on: boolean) => void; clear: () => void; } @@ -21,8 +25,11 @@ export const useStaff = create()( (set) => ({ token: null, username: null, + livePay: false, setStaff: (token, username) => set({ token, username }), - clear: () => set({ token: null, username: null }), + setLivePay: (livePay) => set({ livePay }), + // Выход сотрудника гасит и боевой режим: следующий раз он включается осознанно. + clear: () => set({ token: null, username: null, livePay: false }), }), { name: "pp-driver-staff" } )