feat(pwa): тумблер боевой оплаты в отладке
Выключен по умолчанию: пополнение под просмотром открывает учебный экран. Включён — платёж уходит в банк, и над кнопкой «Оплатить» появляется предупреждение, что деньги спишутся по-настоящему и зачислятся водителю. Режим гаснет вместе с выходом из учётки сотрудника: следующий раз он включается осознанно, а не достаётся по наследству. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 () =>
|
||||
|
||||
@@ -68,10 +68,30 @@ export function DebugPage() {
|
||||
)}
|
||||
</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">
|
||||
<p className="text-muted text-xs mb-2">Сведения</p>
|
||||
<Row k="driver_id" v={String(driverId ?? "—")} />
|
||||
<Row k="режим" v={impersonated ? "просмотр сотрудником (imp)" : "обычный вход водителя"} />
|
||||
{impersonated && (
|
||||
<Row k="оплата" v={staff.livePay ? "боевая — деньги двигаются" : "учебная"} />
|
||||
)}
|
||||
<Row k="сотрудник" v={staff.username ?? "не входил"} />
|
||||
<Row k="токен истекает" v={expiresIn(info?.exp ?? null)} />
|
||||
<Row k="API" v={API_BASE} />
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
<Segment value={method} onChange={setMethod} options={[{ value: "qr", label: "СБП" }, { value: "card", label: "Картой" }]} />
|
||||
</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}>
|
||||
{busy ? <Spinner /> : "Оплатить"}
|
||||
</button>
|
||||
|
||||
@@ -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<StaffState>()(
|
||||
(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" }
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user