From aff1a4500673490472e2d772e3a6788b786c1a58 Mon Sep 17 00:00:00 2001 From: vladtechno Date: Sun, 16 Aug 2026 23:54:04 +1000 Subject: [PATCH] =?UTF-8?q?feat(pwa):=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=D1=85?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0,=20=D0=BA=D0=BE=D0=BF=D0=B5=D0=B9=D0=BA?= =?UTF-8?q?=D0=B8,=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=B3=D0=BE=D1=80=D0=B8=D0=B8=20=D0=B4=D0=BE=D0=BB=D0=B3?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pairing-сессия хранит poll_secret и confirm_code (lib/pairing), опрос ушёл на POST: pair_session уезжал в query-параметре и оседал в access-логах, а это половина ключа к JWT. Вторая половина в deep-link не уходит; - на экране ожидания видно число, которое обязан назвать бот. По чужой ссылке человек его не увидит — подтверждать нечего, токен не выдаётся; - онбординг больше не просит вернуться и запросить код: Telegram подтверждает номер шарингом контакта, приложение забирает токен опросом; - суммы с копейками. formatMoney округлял до рубля, и «к оплате 350 ₽» расходилось и со списанием банка, и с чеком (комиссия почти всегда даёт копейки). Клавиша разделителя, нарисованная на клавиатуре, наконец работает; - категорию долга выбирает водитель. Хардкод «Долг аренда» отправлял платёж в аренду даже тем, у кого её нет; список приходит с сервера и там же проверяется; - ошибки бэкенда показываются словами (минимум СБП, потолок суммы) вместо молчаливого «не удалось», статусы истории переведены полностью, возврат денег отличим от отказа. Тесты: 33 зелёных, tsc чист. Co-Authored-By: Claude Opus 5 --- driver-pwa/frontend/src/api/driver.test.ts | 11 +++- driver-pwa/frontend/src/api/driver.ts | 60 ++++++++++++++++--- .../frontend/src/hooks/usePairPoll.test.tsx | 20 ++++++- driver-pwa/frontend/src/hooks/usePairPoll.ts | 14 +++-- driver-pwa/frontend/src/lib/amount.test.ts | 16 ++++- driver-pwa/frontend/src/lib/amount.ts | 26 +++++++- driver-pwa/frontend/src/lib/format.test.ts | 6 ++ driver-pwa/frontend/src/lib/format.ts | 17 +++++- driver-pwa/frontend/src/lib/pairing.ts | 45 ++++++++++++++ driver-pwa/frontend/src/pages/BalancePage.tsx | 9 +++ driver-pwa/frontend/src/pages/HistoryPage.tsx | 48 +++++++++++---- driver-pwa/frontend/src/pages/LoginPage.tsx | 21 ++++--- .../src/pages/OnboardingPage.test.tsx | 29 +++++++-- .../frontend/src/pages/OnboardingPage.tsx | 24 +++++++- driver-pwa/frontend/src/pages/TgWaitPage.tsx | 36 +++++++---- .../frontend/src/pages/TopupPage.test.tsx | 29 ++++++++- driver-pwa/frontend/src/pages/TopupPage.tsx | 47 +++++++++++---- 17 files changed, 385 insertions(+), 73 deletions(-) create mode 100644 driver-pwa/frontend/src/lib/pairing.ts diff --git a/driver-pwa/frontend/src/api/driver.test.ts b/driver-pwa/frontend/src/api/driver.test.ts index 8d957d9..6a2a1fe 100644 --- a/driver-pwa/frontend/src/api/driver.test.ts +++ b/driver-pwa/frontend/src/api/driver.test.ts @@ -12,10 +12,11 @@ describe("driver api schemas", () => { }); it("parses auth request onboarding + code_sent", () => { expect(AuthRequestSchema.parse({ status: "code_sent", channel: "tg" }).status).toBe("code_sent"); - const o = AuthRequestSchema.parse({ status: "onboarding", deep_links: { tg: "t", max: "m" } }); + const pair = { pair_session: "S", poll_secret: "SEC", confirm_code: "4217" }; + const o = AuthRequestSchema.parse({ status: "onboarding", deep_links: { tg: "t", max: "m" }, ...pair }); expect(o.status === "onboarding" && o.deep_links.tg).toBe("t"); // prod: MAX bot not configured → backend omits `max`, must still parse (regression guard) - const tgOnly = AuthRequestSchema.parse({ status: "onboarding", deep_links: { tg: "t" } }); + const tgOnly = AuthRequestSchema.parse({ status: "onboarding", deep_links: { tg: "t" }, ...pair }); expect(tgOnly.status === "onboarding" && tgOnly.deep_links.tg).toBe("t"); }); it("parses payments history", () => { @@ -27,8 +28,12 @@ describe("driver api schemas", () => { describe("tg verified-login schemas", () => { it("parses tg session", () => { - const v = TgSessionSchema.parse({ pair_session: "S", deep_link: "https://t.me/x?start=S" }); + const v = TgSessionSchema.parse({ pair_session: "S", poll_secret: "SEC", confirm_code: "4217", + deep_link: "https://t.me/x?start=S" }); expect(v.pair_session).toBe("S"); + expect(v.poll_secret).toBe("SEC"); + // вторая половина ключа в deep-link не уходит + expect(v.deep_link).not.toContain("SEC"); }); it("parses tg poll states", () => { expect(TgPollSchema.parse({ status: "pending" }).status).toBe("pending"); diff --git a/driver-pwa/frontend/src/api/driver.ts b/driver-pwa/frontend/src/api/driver.ts index 45f8af5..fb59e25 100644 --- a/driver-pwa/frontend/src/api/driver.ts +++ b/driver-pwa/frontend/src/api/driver.ts @@ -1,15 +1,37 @@ +import { HTTPError } from "ky"; import { z } from "zod"; import { api } from "./client"; +/** Pairing-сессия входа. `poll_secret` не покидает приложение (в deep-link уходит + * только `pair_session`), `confirm_code` человек сверяет с сообщением бота. */ +export const TgSessionSchema = z.object({ + pair_session: z.string(), + poll_secret: z.string(), + confirm_code: z.string(), + deep_link: z.string().nullable(), +}); + export const AuthRequestSchema = z.union([ z.object({ status: z.literal("code_sent"), channel: z.string() }), - z.object({ status: z.literal("onboarding"), deep_links: z.object({ tg: z.string().optional(), max: z.string().optional() }) }), + z.object({ + status: z.literal("onboarding"), + deep_links: z.object({ tg: z.string().optional(), max: z.string().optional() }), + pair_session: z.string(), + poll_secret: z.string(), + confirm_code: z.string(), + }), ]); export type AuthRequest = z.infer; export const VerifySchema = z.object({ token: z.string(), driver_id: z.number() }); export const BalanceSchema = z.object({ accounts: z.array(z.object({ bucket: z.string(), balance: z.number() })), + // Принято нами, но ещё не проведено в 1С. В сумму баланса не входит. + pending_topup: z.number().default(0), +}); +export const BucketsSchema = z.object({ + buckets: z.array(z.string()), + with_debt: z.array(z.string()), }); export const TopupSchema = z.object({ order_id: z.string(), pay_url: z.string(), commission: z.number(), total: z.number(), @@ -21,23 +43,45 @@ export const PaymentsSchema = z.object({ order_id: z.string(), bucket: z.string(), amount: z.number(), commission: z.number(), method: z.string(), status: z.string(), created_at: z.string().nullable(), paid_at: z.string().nullable(), + reversed_at: z.string().nullable().default(null), })), }); -export const TgSessionSchema = z.object({ - pair_session: z.string(), - deep_link: z.string().nullable(), -}); export const TgPollSchema = z.union([ z.object({ status: z.literal("pending") }), z.object({ status: z.literal("expired") }), z.object({ status: z.literal("authenticated"), token: z.string(), driver_id: z.number() }), ]); +/** + * Текст ошибки от бэкенда. + * + * Раньше все отказы схлопывались в «Не удалось создать платёж», и осмысленные + * сообщения — про минимум СБП, про потолок суммы, про неизвестную категорию — + * до водителя не доходили: экран просто не реагировал. + */ +export async function apiErrorText(err: unknown, fallback: string): Promise { + if (err instanceof HTTPError) { + try { + const body = await err.response.json(); + const detail = (body as { detail?: unknown })?.detail; + if (typeof detail === "string" && detail.trim()) return detail; + } catch { + /* тело не JSON — отдадим запасной текст */ + } + } + return fallback; +} + export const tgSession = async () => TgSessionSchema.parse(await api.post("driver/auth/tg/session").json()); -export const tgPoll = async (pairSession: string) => - TgPollSchema.parse(await api.get("driver/auth/tg/poll", { searchParams: { pair_session: pairSession } }).json()); +/** POST, а не GET: в query-параметре pair_session оседал в access-логах. */ +export const tgPoll = async (pairSession: string, pollSecret: string) => + TgPollSchema.parse( + await api.post("driver/auth/tg/poll", { + json: { pair_session: pairSession, poll_secret: pollSecret }, + }).json(), + ); export const authRequest = async (phone: string) => AuthRequestSchema.parse(await api.post("driver/auth/request", { json: { phone } }).json()); @@ -45,6 +89,8 @@ export const authVerify = async (phone: string, code: string) => VerifySchema.parse(await api.post("driver/auth/verify", { json: { phone, code } }).json()); 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 getPaymentState = async (orderId: string) => diff --git a/driver-pwa/frontend/src/hooks/usePairPoll.test.tsx b/driver-pwa/frontend/src/hooks/usePairPoll.test.tsx index a0b878d..ea8e0c7 100644 --- a/driver-pwa/frontend/src/hooks/usePairPoll.test.tsx +++ b/driver-pwa/frontend/src/hooks/usePairPoll.test.tsx @@ -10,20 +10,34 @@ beforeEach(() => { useAuth.getState().clear(); vi.restoreAllMocks(); }); describe("usePairPoll", () => { it("sets session on authenticated", async () => { vi.spyOn(driver, "tgPoll").mockResolvedValue({ status: "authenticated", token: "T", driver_id: 7 } as any); - const { result } = renderHook(() => usePairPoll("SESS")); + const { result } = renderHook(() => usePairPoll("SESS", "SEC")); await waitFor(() => expect(result.current.status).toBe("authenticated")); expect(useAuth.getState().token).toBe("T"); expect(useAuth.getState().driverId).toBe(7); }); it("stays pending then expired", async () => { vi.spyOn(driver, "tgPoll").mockResolvedValue({ status: "expired" } as any); - const { result } = renderHook(() => usePairPoll("SESS")); + const { result } = renderHook(() => usePairPoll("SESS", "SEC")); await waitFor(() => expect(result.current.status).toBe("expired")); }); it("does nothing when session is null", () => { const spy = vi.spyOn(driver, "tgPoll"); - const { result } = renderHook(() => usePairPoll(null)); + const { result } = renderHook(() => usePairPoll(null, "SEC")); expect(result.current.status).toBe("pending"); expect(spy).not.toHaveBeenCalled(); }); + it("does not poll without the secret half of the key", () => { + // pair_session уходит боту в deep-link и оседает в логах; одной его половины + // для получения токена быть недостаточно. + const spy = vi.spyOn(driver, "tgPoll"); + const { result } = renderHook(() => usePairPoll("SESS", null)); + expect(result.current.status).toBe("pending"); + expect(spy).not.toHaveBeenCalled(); + }); + it("sends both halves to the backend", async () => { + const spy = vi.spyOn(driver, "tgPoll").mockResolvedValue({ status: "expired" } as any); + const { result } = renderHook(() => usePairPoll("SESS", "SEC")); + await waitFor(() => expect(result.current.status).toBe("expired")); + expect(spy).toHaveBeenCalledWith("SESS", "SEC"); + }); }); diff --git a/driver-pwa/frontend/src/hooks/usePairPoll.ts b/driver-pwa/frontend/src/hooks/usePairPoll.ts index b56107a..ac31f22 100644 --- a/driver-pwa/frontend/src/hooks/usePairPoll.ts +++ b/driver-pwa/frontend/src/hooks/usePairPoll.ts @@ -7,13 +7,19 @@ type Status = "pending" | "authenticated" | "expired"; const INTERVAL_MS = 2000; const TIMEOUT_MS = 5 * 60 * 1000; -export function usePairPoll(pairSession: string | null): { status: Status } { +/** + * Опрос pairing-сессии. Нужны ОБЕ половины ключа: `pairSession` уходит боту в + * deep-link, `pollSecret` остаётся только здесь — знать одну сессию (например, + * подсмотрев её в access-логе) для получения токена уже недостаточно. + */ +export function usePairPoll(pairSession: string | null, + pollSecret: string | null): { status: Status } { const [status, setStatus] = useState("pending"); const setSession = useAuth((s) => s.setSession); const startedAt = useRef(0); useEffect(() => { - if (!pairSession) return; + if (!pairSession || !pollSecret) return; let alive = true; let timer: ReturnType; startedAt.current = Date.now(); @@ -21,7 +27,7 @@ export function usePairPoll(pairSession: string | null): { status: Status } { const tick = async () => { if (!alive) return; try { - const r = await tgPoll(pairSession); + const r = await tgPoll(pairSession, pollSecret); if (!alive) return; if (r.status === "authenticated") { setSession(r.token, r.driver_id); @@ -37,7 +43,7 @@ export function usePairPoll(pairSession: string | null): { status: Status } { }; tick(); return () => { alive = false; clearTimeout(timer); }; - }, [pairSession, setSession]); + }, [pairSession, pollSecret, setSession]); return { status }; } diff --git a/driver-pwa/frontend/src/lib/amount.test.ts b/driver-pwa/frontend/src/lib/amount.test.ts index 7370485..c593c7a 100644 --- a/driver-pwa/frontend/src/lib/amount.test.ts +++ b/driver-pwa/frontend/src/lib/amount.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { keypadReduce, withCommission } from "./amount"; +import { keypadReduce, parseAmount, withCommission } from "./amount"; describe("amount keypad", () => { it("appends digits, ignores leading zero, caps length", () => { @@ -16,4 +16,18 @@ describe("amount keypad", () => { expect(withCommission(1000, 0.052)).toEqual({ commission: 52, total: 1052 }); expect(withCommission(333, 0.052)).toEqual({ commission: 17.32, total: 350.32 }); }); + it("separator key builds kopecks — раньше клавиша была нарисована, но не работала", () => { + expect(keypadReduce("100", ",")).toBe("100,"); + expect(keypadReduce("100,", "5")).toBe("100,5"); + expect(keypadReduce("100,5", "2")).toBe("100,52"); + expect(keypadReduce("100,52", "9")).toBe("100,52"); // копеек ровно две + expect(keypadReduce("100,52", ",")).toBe("100,52"); // второй разделитель не нужен + expect(keypadReduce("", ",")).toBe("0,"); + expect(keypadReduce("100,5", "back")).toBe("100,"); + }); + it("parseAmount reads the keypad string", () => { + expect(parseAmount("100,52")).toBe(100.52); + expect(parseAmount("100,")).toBe(100); + expect(parseAmount("")).toBe(0); + }); }); diff --git a/driver-pwa/frontend/src/lib/amount.ts b/driver-pwa/frontend/src/lib/amount.ts index deb7e9f..4caa0aa 100644 --- a/driver-pwa/frontend/src/lib/amount.ts +++ b/driver-pwa/frontend/src/lib/amount.ts @@ -1,14 +1,38 @@ -const MAX_DIGITS = 6; +const MAX_DIGITS = 6; // целая часть +const SEP = ","; // русский разделитель — как на клавише +/** + * Одно нажатие клавиатуры суммы. + * + * Разделитель раньше был нарисован на клавише, но ничего не делал: `press` + * молча игнорировал его, а `keypadReduce` принимал только цифры. Заплатить + * точный долг вроде 1234,56 было нечем. + */ export function keypadReduce(current: string, key: string): string { if (key === "back") return current.slice(0, -1); + if (key === SEP || key === "." || key === "·") { + if (current.includes(SEP)) return current; // второй разделитель не нужен + return current === "" ? "0" + SEP : current + SEP; + } if (!/^\d$/.test(key)) return current; + const [whole, frac] = current.split(SEP); + if (frac !== undefined) { + if (frac.length >= 2) return current; // копеек ровно две + return `${whole}${SEP}${frac}${key}`; + } if (current === "0") return key === "0" ? "0" : key; // replace leading zero if (current === "" && key === "0") return ""; // no leading zero from empty if (current.length >= MAX_DIGITS) return current; return current + key; } +/** Строка с клавиатуры → число рублей. Незавершённое «100,» читается как 100. */ +export function parseAmount(raw: string): number { + if (!raw) return 0; + const n = parseFloat(raw.replace(SEP, ".")); + return Number.isFinite(n) ? Math.round(n * 100) / 100 : 0; +} + export function withCommission(base: number, rate: number): { commission: number; total: number } { const commission = Math.round(base * rate * 100) / 100; const total = Math.round((base + commission) * 100) / 100; diff --git a/driver-pwa/frontend/src/lib/format.test.ts b/driver-pwa/frontend/src/lib/format.test.ts index 8c60206..00ba530 100644 --- a/driver-pwa/frontend/src/lib/format.test.ts +++ b/driver-pwa/frontend/src/lib/format.test.ts @@ -7,6 +7,12 @@ describe("format", () => { expect(formatMoney(-3200)).toBe("−3 200 ₽"); expect(formatMoney(0)).toBe("0 ₽"); }); + it("formatMoney keeps kopecks — раньше Math.round() показывал не ту сумму, что спишет банк", () => { + expect(formatMoney(350.32)).toBe("350,32 ₽"); + expect(formatMoney(1052)).toBe("1 052 ₽"); + expect(formatMoney(10.5)).toBe("10,50 ₽"); + expect(formatMoney(-350.32)).toBe("−350,32 ₽"); + }); it("digitsOnly strips non-digits", () => { expect(digitsOnly("+7 (914) 305-44-00")).toBe("79143054400"); }); diff --git a/driver-pwa/frontend/src/lib/format.ts b/driver-pwa/frontend/src/lib/format.ts index 730e394..21d17df 100644 --- a/driver-pwa/frontend/src/lib/format.ts +++ b/driver-pwa/frontend/src/lib/format.ts @@ -2,11 +2,22 @@ export function digitsOnly(s: string): string { return (s || "").replace(/\D/g, ""); } +/** + * Деньги для показа. Копейки печатаем ТОЛЬКО когда они есть. + * + * Раньше здесь было Math.round(), и водителю показывали одну сумму, а банк + * списывал другую: комиссия 5.2% почти всегда даёт копейки (1000 → 1052,00, но + * 333 → 350,32), и «к оплате 350 ₽» расходилось и со списанием, и с чеком. + */ export function formatMoney(rub: number): string { const neg = rub < 0; - const abs = Math.abs(Math.round(rub)); - const grouped = abs.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " "); - return `${neg ? "−" : ""}${grouped} ₽`; + const abs = Math.abs(rub); + const kop = Math.round(abs * 100); + const whole = Math.floor(kop / 100); + const rest = kop % 100; + const grouped = whole.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " "); + const tail = rest ? `,${rest.toString().padStart(2, "0")}` : ""; + return `${neg ? "−" : ""}${grouped}${tail} ₽`; } /** Render a Russian mobile from up-to-10 national digits as +7 914 305-44-00. */ diff --git a/driver-pwa/frontend/src/lib/pairing.ts b/driver-pwa/frontend/src/lib/pairing.ts new file mode 100644 index 0000000..46d3686 --- /dev/null +++ b/driver-pwa/frontend/src/lib/pairing.ts @@ -0,0 +1,45 @@ +/** + * Pairing-сессия входа через Telegram, пока приложение ждёт бота. + * + * Держим в sessionStorage: экран ожидания переживает уход в Telegram и возврат, + * но сессия не должна пережить закрытие вкладки — это половина ключа к JWT. + * Раньше ключи раскладывались по три штуки в каждом экране; теперь одно место, + * чтобы `poll_secret` нельзя было забыть положить или забыть стереть. + */ +const K_SESSION = "pp-pair-session"; +const K_SECRET = "pp-pair-secret"; +const K_CONFIRM = "pp-pair-confirm"; +const K_LINK = "pp-pair-deeplink"; + +export interface Pairing { + pairSession: string; + pollSecret: string; + confirmCode: string; + deepLink: string | null; +} + +export function savePairing(p: { + pair_session: string; poll_secret: string; confirm_code: string; deep_link?: string | null; +}): void { + sessionStorage.setItem(K_SESSION, p.pair_session); + sessionStorage.setItem(K_SECRET, p.poll_secret); + sessionStorage.setItem(K_CONFIRM, p.confirm_code); + if (p.deep_link) sessionStorage.setItem(K_LINK, p.deep_link); + else sessionStorage.removeItem(K_LINK); +} + +export function loadPairing(): Pairing | null { + const pairSession = sessionStorage.getItem(K_SESSION); + const pollSecret = sessionStorage.getItem(K_SECRET); + if (!pairSession || !pollSecret) return null; + return { + pairSession, + pollSecret, + confirmCode: sessionStorage.getItem(K_CONFIRM) ?? "", + deepLink: sessionStorage.getItem(K_LINK), + }; +} + +export function clearPairing(): void { + [K_SESSION, K_SECRET, K_CONFIRM, K_LINK].forEach((k) => sessionStorage.removeItem(k)); +} diff --git a/driver-pwa/frontend/src/pages/BalancePage.tsx b/driver-pwa/frontend/src/pages/BalancePage.tsx index 169d3a5..0007836 100644 --- a/driver-pwa/frontend/src/pages/BalancePage.tsx +++ b/driver-pwa/frontend/src/pages/BalancePage.tsx @@ -14,6 +14,7 @@ export function BalancePage() { const accounts = data?.accounts ?? []; const total = accounts.reduce((s, a) => s + a.balance, 0); + const pending = data?.pending_topup ?? 0; return (
@@ -21,6 +22,14 @@ export function BalancePage() {

Баланс

{formatMoney(total)}

+ {pending > 0 && ( + // Платёж принят нами, но 1С его ещё не провела. В сумму баланса он не + // входит намеренно: сложить оба источника значило бы показать + // пополнение дважды, как только расчётный лист принесёт тот же платёж. +

+ {formatMoney(pending)} зачисляется — учтётся после проведения +

+ )}
{isLoading ? ( diff --git a/driver-pwa/frontend/src/pages/HistoryPage.tsx b/driver-pwa/frontend/src/pages/HistoryPage.tsx index de072db..683b84a 100644 --- a/driver-pwa/frontend/src/pages/HistoryPage.tsx +++ b/driver-pwa/frontend/src/pages/HistoryPage.tsx @@ -4,10 +4,30 @@ import { getPayments } from "@/api/driver"; import { formatMoney } from "@/lib/format"; import { Spinner } from "@/components/Spinner"; +/** + * Все статусы, которые может прислать бэкенд. Раньше их было пять из тринадцати, + * и водитель видел в истории «DEADLINE_EXPIRED» или «ATTEMPTS_EXPIRED» латиницей, + * а возврат денег выглядел ровно как обычный отказ. + */ const STATUS_RU: Record = { - PAID: "Оплачено", NEW: "Ожидает", FORM: "Ожидает", CONFIRMED: "Оплачено", REJECTED: "Отклонён", + NEW: "Ожидает оплаты", + FORM: "Ожидает оплаты", + PAID: "Оплачено", + CONFIRMED: "Оплачено", + REJECTED: "Отклонён банком", + AUTH_FAIL: "Отклонён банком", + CANCELED: "Отменён", + DEADLINE_EXPIRED: "Истёк срок оплаты", + ATTEMPTS_EXPIRED: "Исчерпаны попытки оплаты", + ERROR: "Ошибка", + REVERSED: "Деньги возвращены", + REFUNDED: "Деньги возвращены", + PARTIAL_REVERSED: "Возвращена часть", + PARTIAL_REFUNDED: "Возвращена часть", }; +const RETURNED = new Set(["REVERSED", "REFUNDED", "PARTIAL_REVERSED", "PARTIAL_REFUNDED"]); + export function HistoryPage() { const nav = useNavigate(); const { data, isLoading, isError } = useQuery({ queryKey: ["payments"], queryFn: getPayments }); @@ -24,17 +44,23 @@ export function HistoryPage() { ) : rows.length === 0 ? (

Пока нет пополнений

) : ( - rows.map((p) => ( -
-
-

{p.bucket}

-

- {p.created_at ? new Date(p.created_at).toLocaleDateString("ru-RU") : ""} · {STATUS_RU[p.status] ?? p.status} -

+ rows.map((p) => { + const returned = RETURNED.has(p.status) || !!p.reversed_at; + return ( +
+
+

{p.bucket}

+

+ {p.created_at ? new Date(p.created_at).toLocaleDateString("ru-RU") : ""} ·{" "} + {STATUS_RU[p.status] ?? p.status} +

+
+ + {formatMoney(p.amount)} +
- {formatMoney(p.amount)} -
- )) + ); + }) )}
); diff --git a/driver-pwa/frontend/src/pages/LoginPage.tsx b/driver-pwa/frontend/src/pages/LoginPage.tsx index b18c215..eb902ee 100644 --- a/driver-pwa/frontend/src/pages/LoginPage.tsx +++ b/driver-pwa/frontend/src/pages/LoginPage.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; -import { authRequest, authVerify, tgSession } from "@/api/driver"; +import { apiErrorText, authRequest, authVerify, tgSession } from "@/api/driver"; import { digitsOnly, formatPhone } from "@/lib/format"; +import { savePairing } from "@/lib/pairing"; import { useAuth } from "@/store/auth"; import { Spinner } from "@/components/Spinner"; @@ -18,7 +19,8 @@ export function LoginPage() { // Prefetched pairing session so the «Войти через Telegram» control is a real // the OS opens in Telegram (a synchronous user gesture) WITHOUT // navigating the PWA away — the app stays mounted on /tg-wait and polls. - const [tg, setTg] = useState<{ pair_session: string; deep_link: string | null } | null>(null); + const [tg, setTg] = useState<{ pair_session: string; poll_secret: string; + confirm_code: string; deep_link: string | null } | null>(null); const e164 = "7" + phone; useEffect(() => { @@ -29,8 +31,7 @@ export function LoginPage() { function startTg() { if (!tg) return; - sessionStorage.setItem("pp-pair-session", tg.pair_session); - if (tg.deep_link) sessionStorage.setItem("pp-pair-deeplink", tg.deep_link); + savePairing(tg); nav("/tg-wait"); } @@ -40,6 +41,10 @@ export function LoginPage() { try { const r = await authRequest(e164); if (r.status === "onboarding") { + // Онбординг теперь идёт той же pairing-сессией: бот попросит поделиться + // контактом, Telegram подтвердит номер, приложение заберёт токен опросом. + // Второй раз номер вводить не нужно. + savePairing({ ...r, deep_link: r.deep_links.tg ?? null }); sessionStorage.setItem("pp-onboarding", JSON.stringify({ ...r.deep_links, phone })); nav("/onboarding"); return; @@ -48,11 +53,11 @@ export function LoginPage() { setStep("code"); } catch (err: unknown) { const status = (err as { response?: { status?: number } })?.response?.status; - const msg = + const fallback = status === 404 ? "Номер не найден. Обратитесь в парк." : status === 429 ? "Слишком часто. Попробуйте позже." : "Не удалось отправить код"; - toast.error(msg); + toast.error(await apiErrorText(err, fallback)); } finally { setBusy(false); } @@ -65,8 +70,8 @@ export function LoginPage() { const r = await authVerify(e164, code); setSession(r.token, r.driver_id); nav("/"); - } catch { - toast.error("Неверный или просроченный код"); + } catch (err: unknown) { + toast.error(await apiErrorText(err, "Неверный или просроченный код")); } finally { setBusy(false); } diff --git a/driver-pwa/frontend/src/pages/OnboardingPage.test.tsx b/driver-pwa/frontend/src/pages/OnboardingPage.test.tsx index 9f02a07..2b65704 100644 --- a/driver-pwa/frontend/src/pages/OnboardingPage.test.tsx +++ b/driver-pwa/frontend/src/pages/OnboardingPage.test.tsx @@ -2,22 +2,41 @@ import { describe, it, expect, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { OnboardingPage } from "./OnboardingPage"; +import { savePairing, clearPairing } from "@/lib/pairing"; describe("OnboardingPage", () => { - beforeEach(() => - sessionStorage.setItem("pp-onboarding", JSON.stringify({ tg: "https://t.me/b?start=x", max: "https://max.ru/b?start=x", phone: "9143054400" })) - ); - it("shows both bot links + back-to-code action", () => { + beforeEach(() => { + clearPairing(); + sessionStorage.setItem("pp-onboarding", JSON.stringify({ tg: "https://t.me/b?start=x", max: "https://max.ru/b?start=x", phone: "9143054400" })); + }); + + it("shows both bot links + back to login", () => { render(); expect(screen.getByText(/Подключите бот/i)).toBeInTheDocument(); expect(screen.getByRole("link", { name: /Telegram/i })).toHaveAttribute("href", "https://t.me/b?start=x"); expect(screen.getByRole("link", { name: /MAX/i })).toHaveAttribute("href", "https://max.ru/b?start=x"); - expect(screen.getByRole("button", { name: /запросить код/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Назад ко входу/i })).toBeInTheDocument(); }); + it("renders only Telegram when max is absent", () => { sessionStorage.setItem("pp-onboarding", JSON.stringify({ tg: "https://t.me/ppdriver_bot?start=x", phone: "9143054400" })); render(); expect(screen.getByRole("link", { name: /Telegram/i })).toBeInTheDocument(); expect(screen.queryByRole("link", { name: /MAX/i })).toBeNull(); }); + + it("shows the confirm code the bot must repeat", () => { + // Защита от фишинга: если ссылку прислал посторонний, у человека этого числа + // на экране нет — значит и делиться номером не нужно. + savePairing({ pair_session: "S", poll_secret: "SEC", confirm_code: "4217", deep_link: "https://t.me/b?start=S" }); + render(); + expect(screen.getByText("4217")).toBeInTheDocument(); + }); + + it("no longer asks the driver to come back for a code", () => { + // Онбординг идёт той же pairing-сессией: Telegram подтверждает номер, и + // приложение забирает токен опросом — второй раз номер вводить не нужно. + render(); + expect(screen.queryByRole("button", { name: /запросить код/i })).toBeNull(); + }); }); diff --git a/driver-pwa/frontend/src/pages/OnboardingPage.tsx b/driver-pwa/frontend/src/pages/OnboardingPage.tsx index e673dfe..215f6a3 100644 --- a/driver-pwa/frontend/src/pages/OnboardingPage.tsx +++ b/driver-pwa/frontend/src/pages/OnboardingPage.tsx @@ -1,19 +1,37 @@ import { useNavigate } from "react-router-dom"; +import { loadPairing } from "@/lib/pairing"; export function OnboardingPage() { const nav = useNavigate(); const raw = sessionStorage.getItem("pp-onboarding"); const data = raw ? (JSON.parse(raw) as { tg?: string; max?: string; phone: string }) : null; + const pairing = loadPairing(); if (!data) return null; return (

Подключите бот

- Чтобы получать код входа, откройте бот и нажмите «Старт». Затем вернитесь и запросите код снова. + Откройте бот и поделитесь своим номером — Telegram подтвердит его, и вход + произойдёт автоматически. Возвращаться за кодом не нужно.

+ {pairing?.confirmCode && ( +
+

Код подтверждения

+

{pairing.confirmCode}

+

+ Бот назовёт это число. Не совпало — закройте бот и не делитесь номером. +

+
+ )} {data.tg && ( -
+ nav("/tg-wait")} + > Подключить Telegram )} @@ -23,7 +41,7 @@ export function OnboardingPage() { )}
); diff --git a/driver-pwa/frontend/src/pages/TgWaitPage.tsx b/driver-pwa/frontend/src/pages/TgWaitPage.tsx index dc2d21a..ed87a9c 100644 --- a/driver-pwa/frontend/src/pages/TgWaitPage.tsx +++ b/driver-pwa/frontend/src/pages/TgWaitPage.tsx @@ -1,27 +1,25 @@ import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { usePairPoll } from "@/hooks/usePairPoll"; +import { clearPairing, loadPairing } from "@/lib/pairing"; import { Spinner } from "@/components/Spinner"; export function TgWaitPage() { const nav = useNavigate(); - const pairSession = sessionStorage.getItem("pp-pair-session"); - const deepLink = sessionStorage.getItem("pp-pair-deeplink"); - const { status } = usePairPoll(pairSession); + const pairing = loadPairing(); + const { status } = usePairPoll(pairing?.pairSession ?? null, pairing?.pollSecret ?? null); useEffect(() => { - if (!pairSession) { nav("/login", { replace: true }); } - }, [pairSession, nav]); + if (!pairing) { nav("/login", { replace: true }); } + }, [pairing, nav]); useEffect(() => { if (status === "authenticated") { - sessionStorage.removeItem("pp-pair-session"); - sessionStorage.removeItem("pp-pair-deeplink"); + clearPairing(); nav("/", { replace: true }); } if (status === "expired") { - sessionStorage.removeItem("pp-pair-session"); - sessionStorage.removeItem("pp-pair-deeplink"); + clearPairing(); nav("/login", { replace: true, state: { expired: true } }); } }, [status, nav]); @@ -30,10 +28,24 @@ export function TgWaitPage() {

Премиум Водитель

- В Telegram нажмите «Поделиться номером для входа», затем вернитесь сюда — вход произойдёт автоматически. + Бот назовёт число ниже и попросит подтвердить вход — нажмите «Это я, войти» + (или поделитесь номером, если входите впервые). Затем вернитесь сюда, вход + произойдёт автоматически.

- {deepLink && ( - + {pairing?.confirmCode && ( + // Число сверки: бот называет его в своём сообщении. Если человек попал в + // бот по чужой ссылке, у него на экране этого числа нет — подтверждать + // нечего, и токен не выдаётся. +
+

Код подтверждения

+

{pairing.confirmCode}

+

+ Это же число должен назвать бот. Не совпало — нажмите в боте «Это не я». +

+
+ )} + {pairing?.deepLink && ( +
Открыть Telegram )} diff --git a/driver-pwa/frontend/src/pages/TopupPage.test.tsx b/driver-pwa/frontend/src/pages/TopupPage.test.tsx index 2e46485..2fdd690 100644 --- a/driver-pwa/frontend/src/pages/TopupPage.test.tsx +++ b/driver-pwa/frontend/src/pages/TopupPage.test.tsx @@ -7,12 +7,16 @@ 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 })), getCommission: vi.fn(async () => ({ rate: 0.052 })), + getBuckets: vi.fn(async () => ({ + buckets: ["Долг аренда", "Долг по штрафам"], with_debt: ["Долг аренда"], + })), + apiErrorText: vi.fn(async (_e: unknown, fallback: string) => fallback), })); import { TopupPage } from "./TopupPage"; import * as driver from "@/api/driver"; const wrap = () => { - const qc = new QueryClient(); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( @@ -32,4 +36,27 @@ describe("TopupPage", () => { await userEvent.click(screen.getByRole("button", { name: /Оплатить/i })); expect(driver.topup).toHaveBeenCalledWith("Долг аренда", 1000, "qr"); }); + + it("decimal key builds kopecks and they reach the backend", async () => { + // Клавиша разделителя была нарисована, но ничего не делала: заплатить точный + // долг вроде 1234,56 было нечем. + wrap(); + for (const k of ["1", "0", "0", ",", "5", "2"]) { + await userEvent.click(screen.getByRole("button", { name: k })); + } + expect(screen.getByTestId("amount").textContent).toBe("100,52 ₽"); + await userEvent.click(screen.getByRole("button", { name: /Оплатить/i })); + expect(driver.topup).toHaveBeenCalledWith("Долг аренда", 100.52, "qr"); + }); + + it("lets the driver choose the debt category instead of hardcoding rent", async () => { + wrap(); + await screen.findByRole("button", { name: "Долг по штрафам" }); + await userEvent.click(screen.getByRole("button", { name: "Долг по штрафам" })); + 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).toHaveBeenCalledWith("Долг по штрафам", 500, "qr"); + }); }); diff --git a/driver-pwa/frontend/src/pages/TopupPage.tsx b/driver-pwa/frontend/src/pages/TopupPage.tsx index 2c56620..3dbea2c 100644 --- a/driver-pwa/frontend/src/pages/TopupPage.tsx +++ b/driver-pwa/frontend/src/pages/TopupPage.tsx @@ -3,41 +3,49 @@ import { useLocation, useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { Delete } from "lucide-react"; import { toast } from "sonner"; -import { topup, getCommission } from "@/api/driver"; -import { keypadReduce, withCommission } from "@/lib/amount"; +import { apiErrorText, topup, getCommission, getBuckets } from "@/api/driver"; +import { keypadReduce, parseAmount, withCommission } from "@/lib/amount"; import { formatMoney } from "@/lib/format"; import { Segment } from "@/components/Segment"; import { Spinner } from "@/components/Spinner"; const QUICK = ["500", "1000", "2000"]; -const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "·", "0", "back"]; +const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", ",", "0", "back"]; export function TopupPage() { const nav = useNavigate(); const loc = useLocation(); - const bucket = (loc.state as { bucket?: string } | null)?.bucket ?? "Долг аренда"; + const fromBalance = (loc.state as { bucket?: string } | null)?.bucket; + // Список категорий приходит с сервера — там же он и проверяется. Хардкод + // «Долг аренда» отправлял платёж в аренду даже тем, у кого её нет. + const { data: bucketData } = useQuery({ queryKey: ["buckets"], queryFn: getBuckets }); + const options = bucketData?.buckets ?? (fromBalance ? [fromBalance] : []); + const [picked, setPicked] = useState(null); + const bucket = picked ?? fromBalance ?? bucketData?.with_debt[0] ?? options[0] ?? ""; + const [raw, setRaw] = useState(""); const [method, setMethod] = useState("qr"); const [busy, setBusy] = useState(false); const { data: commission } = useQuery({ queryKey: ["commission"], queryFn: getCommission }); const rate = commission?.rate ?? 0.052; // fallback only while loading - const base = raw ? parseInt(raw, 10) : 0; + const base = parseAmount(raw); const { commission: fee, total } = withCommission(base, rate); function press(k: string) { - if (k === "·") return; setRaw((r) => keypadReduce(r, k)); } async function pay() { - if (base <= 0) return; + if (base <= 0 || !bucket || busy) return; setBusy(true); try { const r = await topup(bucket, base, method as "qr" | "card"); nav(`/pay/${r.order_id}`, { state: { payUrl: r.pay_url, total: r.total, bucket, base } }); - } catch { - toast.error("Не удалось создать платёж"); + } catch (err: unknown) { + // Текст с сервера, а не глухое «не удалось»: минимум СБП, потолок суммы и + // неизвестная категория объясняются словами, иначе экран молча не реагирует. + toast.error(await apiErrorText(err, "Не удалось создать платёж")); } finally { setBusy(false); } @@ -45,7 +53,24 @@ export function TopupPage() { return (
- + + + {options.length > 1 && ( +
+ {options.map((b) => ( + + ))} +
+ )} +

{formatMoney(base)}

к оплате {formatMoney(total)} (комиссия {formatMoney(fee)}) @@ -76,7 +101,7 @@ export function TopupPage() {

-