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());
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" }
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user