feat(pwa): тумблер боевой оплаты в отладке

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

Режим гаснет вместе с выходом из учётки сотрудника: следующий раз он включается
осознанно, а не достаётся по наследству.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 17:30:55 +10:00
co-authored by Claude Opus 5
parent 7342a6c9d5
commit 9aa71d6ca7
5 changed files with 74 additions and 8 deletions
+5 -2
View File
@@ -104,8 +104,11 @@ export const getBalance = async () =>
BalanceSchema.parse(await api.get("driver/balance").json()); BalanceSchema.parse(await api.get("driver/balance").json());
export const getBuckets = async () => export const getBuckets = async () =>
BucketsSchema.parse(await api.get("driver/buckets").json()); BucketsSchema.parse(await api.get("driver/buckets").json());
export const topup = async (bucket: string, amount: number, method: "qr" | "card") => export const topup = async (bucket: string, amount: number, method: "qr" | "card",
TopupSchema.parse(await api.post("driver/topup", { json: { bucket, amount, method } }).json()); live = false) =>
TopupSchema.parse(await api.post("driver/topup", {
json: { bucket, amount, method, live },
}).json());
export const getPaymentState = async (orderId: string) => export const getPaymentState = async (orderId: string) =>
PaymentStateSchema.parse(await api.get(`driver/payment/${orderId}`).json()); PaymentStateSchema.parse(await api.get(`driver/payment/${orderId}`).json());
export const getPayments = async () => export const getPayments = async () =>
@@ -68,10 +68,30 @@ export function DebugPage() {
)} )}
</div> </div>
{impersonated && (
<div className="card p-4 mb-4">
<p className="text-sm font-semibold mb-1">Боевая оплата</p>
<p className="text-muted text-xs mb-3">
Выключено пополнение открывает учебный экран, деньги не двигаются.
Включено платёж уходит в банк по-настоящему: спишется с вашей карты,
чек уйдёт на телефон водителя, сумма зачислится ему в 1С.
</p>
<button
className={staff.livePay ? "btn-primary" : "btn-ghost"}
onClick={() => staff.setLivePay(!staff.livePay)}
>
{staff.livePay ? "Боевая оплата включена — выключить" : "Включить боевую оплату"}
</button>
</div>
)}
<div className="card p-4 mb-4"> <div className="card p-4 mb-4">
<p className="text-muted text-xs mb-2">Сведения</p> <p className="text-muted text-xs mb-2">Сведения</p>
<Row k="driver_id" v={String(driverId ?? "—")} /> <Row k="driver_id" v={String(driverId ?? "—")} />
<Row k="режим" v={impersonated ? "просмотр сотрудником (imp)" : "обычный вход водителя"} /> <Row k="режим" v={impersonated ? "просмотр сотрудником (imp)" : "обычный вход водителя"} />
{impersonated && (
<Row k="оплата" v={staff.livePay ? "боевая — деньги двигаются" : "учебная"} />
)}
<Row k="сотрудник" v={staff.username ?? "не входил"} /> <Row k="сотрудник" v={staff.username ?? "не входил"} />
<Row k="токен истекает" v={expiresIn(info?.exp ?? null)} /> <Row k="токен истекает" v={expiresIn(info?.exp ?? null)} />
<Row k="API" v={API_BASE} /> <Row k="API" v={API_BASE} />
@@ -5,7 +5,8 @@ import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("@/api/driver", () => ({ 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 })), getCommission: vi.fn(async () => ({ rate: 0.052 })),
getBuckets: vi.fn(async () => ({ getBuckets: vi.fn(async () => ({
buckets: ["Долг аренда", "Долг по штрафам"], with_debt: ["Долг аренда"], buckets: ["Долг аренда", "Долг по штрафам"], with_debt: ["Долг аренда"],
@@ -34,7 +35,7 @@ describe("TopupPage", () => {
expect(screen.getByTestId("amount").textContent).toBe("1 000 ₽"); expect(screen.getByTestId("amount").textContent).toBe("1 000 ₽");
expect(screen.getByTestId("total").textContent).toContain("1 052"); expect(screen.getByTestId("total").textContent).toContain("1 052");
await userEvent.click(screen.getByRole("button", { name: /Оплатить/i })); 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 () => { 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 ₽"); expect(screen.getByTestId("amount").textContent).toBe("100,52 ₽");
await userEvent.click(screen.getByRole("button", { name: /Оплатить/i })); 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 () => { 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: "0" })); await userEvent.click(screen.getByRole("button", { name: "0" }));
await userEvent.click(screen.getByRole("button", { name: /Оплатить/i })); 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();
}); });
}); });
+10 -1
View File
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { apiErrorText, topup, getCommission, getBuckets } from "@/api/driver"; import { apiErrorText, topup, getCommission, getBuckets } from "@/api/driver";
import { keypadReduce, parseAmount, withCommission } from "@/lib/amount"; import { keypadReduce, parseAmount, withCommission } from "@/lib/amount";
import { formatMoney } from "@/lib/format"; import { formatMoney } from "@/lib/format";
import { useStaff } from "@/store/staff";
import { Segment } from "@/components/Segment"; import { Segment } from "@/components/Segment";
import { Spinner } from "@/components/Spinner"; 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() { export function TopupPage() {
const nav = useNavigate(); const nav = useNavigate();
const livePay = useStaff((s) => s.livePay);
const loc = useLocation(); const loc = useLocation();
const fromBalance = (loc.state as { bucket?: string } | null)?.bucket; const fromBalance = (loc.state as { bucket?: string } | null)?.bucket;
// Список категорий приходит с сервера — там же он и проверяется. Хардкод // Список категорий приходит с сервера — там же он и проверяется. Хардкод
@@ -40,7 +42,7 @@ export function TopupPage() {
if (base <= 0 || !bucket || busy) return; if (base <= 0 || !bucket || busy) return;
setBusy(true); setBusy(true);
try { 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) { if (r.mock) {
@@ -113,6 +115,13 @@ export function TopupPage() {
<Segment value={method} onChange={setMethod} options={[{ value: "qr", label: "СБП" }, { value: "card", label: "Картой" }]} /> <Segment value={method} onChange={setMethod} options={[{ value: "qr", label: "СБП" }, { value: "card", label: "Картой" }]} />
</div> </div>
{livePay && (
// Предупреждение прямо над кнопкой: под чужим лицом ошибиться легко, а
// отыграть боевой платёж — это возврат в банке и правка в 1С.
<p className="text-neg text-xs mb-2 text-center">
Боевая оплата: деньги спишутся по-настоящему и зачислятся водителю
</p>
)}
<button className="btn-primary" disabled={busy || base <= 0 || !bucket} onClick={pay}> <button className="btn-primary" disabled={busy || base <= 0 || !bucket} onClick={pay}>
{busy ? <Spinner /> : "Оплатить"} {busy ? <Spinner /> : "Оплатить"}
</button> </button>
+8 -1
View File
@@ -12,7 +12,11 @@ import { persist } from "zustand/middleware";
interface StaffState { interface StaffState {
token: string | null; token: string | null;
username: string | null; username: string | null;
/** Боевая оплата под просмотром: платёж уходит в банк и зачисляется водителю.
* Выключено по умолчанию — под чужим лицом легко забыть, кого пополняешь. */
livePay: boolean;
setStaff: (token: string, username: string) => void; setStaff: (token: string, username: string) => void;
setLivePay: (on: boolean) => void;
clear: () => void; clear: () => void;
} }
@@ -21,8 +25,11 @@ export const useStaff = create<StaffState>()(
(set) => ({ (set) => ({
token: null, token: null,
username: null, username: null,
livePay: false,
setStaff: (token, username) => set({ token, username }), 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" } { name: "pp-driver-staff" }
) )