feat(pwa): подтверждение входа, копейки, выбор категории долга
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -12,10 +12,11 @@ describe("driver api schemas", () => {
|
|||||||
});
|
});
|
||||||
it("parses auth request onboarding + code_sent", () => {
|
it("parses auth request onboarding + code_sent", () => {
|
||||||
expect(AuthRequestSchema.parse({ status: "code_sent", channel: "tg" }).status).toBe("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");
|
expect(o.status === "onboarding" && o.deep_links.tg).toBe("t");
|
||||||
// prod: MAX bot not configured → backend omits `max`, must still parse (regression guard)
|
// 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");
|
expect(tgOnly.status === "onboarding" && tgOnly.deep_links.tg).toBe("t");
|
||||||
});
|
});
|
||||||
it("parses payments history", () => {
|
it("parses payments history", () => {
|
||||||
@@ -27,8 +28,12 @@ describe("driver api schemas", () => {
|
|||||||
|
|
||||||
describe("tg verified-login schemas", () => {
|
describe("tg verified-login schemas", () => {
|
||||||
it("parses tg session", () => {
|
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.pair_session).toBe("S");
|
||||||
|
expect(v.poll_secret).toBe("SEC");
|
||||||
|
// вторая половина ключа в deep-link не уходит
|
||||||
|
expect(v.deep_link).not.toContain("SEC");
|
||||||
});
|
});
|
||||||
it("parses tg poll states", () => {
|
it("parses tg poll states", () => {
|
||||||
expect(TgPollSchema.parse({ status: "pending" }).status).toBe("pending");
|
expect(TgPollSchema.parse({ status: "pending" }).status).toBe("pending");
|
||||||
|
|||||||
@@ -1,15 +1,37 @@
|
|||||||
|
import { HTTPError } from "ky";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { api } from "./client";
|
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([
|
export const AuthRequestSchema = z.union([
|
||||||
z.object({ status: z.literal("code_sent"), channel: z.string() }),
|
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<typeof AuthRequestSchema>;
|
export type AuthRequest = z.infer<typeof AuthRequestSchema>;
|
||||||
|
|
||||||
export const VerifySchema = z.object({ token: z.string(), driver_id: z.number() });
|
export const VerifySchema = z.object({ token: z.string(), driver_id: z.number() });
|
||||||
export const BalanceSchema = z.object({
|
export const BalanceSchema = z.object({
|
||||||
accounts: z.array(z.object({ bucket: z.string(), balance: z.number() })),
|
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({
|
export const TopupSchema = z.object({
|
||||||
order_id: z.string(), pay_url: z.string(), commission: z.number(), total: z.number(),
|
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(),
|
order_id: z.string(), bucket: z.string(), amount: z.number(), commission: z.number(),
|
||||||
method: z.string(), status: z.string(),
|
method: z.string(), status: z.string(),
|
||||||
created_at: z.string().nullable(), paid_at: z.string().nullable(),
|
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([
|
export const TgPollSchema = z.union([
|
||||||
z.object({ status: z.literal("pending") }),
|
z.object({ status: z.literal("pending") }),
|
||||||
z.object({ status: z.literal("expired") }),
|
z.object({ status: z.literal("expired") }),
|
||||||
z.object({ status: z.literal("authenticated"), token: z.string(), driver_id: z.number() }),
|
z.object({ status: z.literal("authenticated"), token: z.string(), driver_id: z.number() }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Текст ошибки от бэкенда.
|
||||||
|
*
|
||||||
|
* Раньше все отказы схлопывались в «Не удалось создать платёж», и осмысленные
|
||||||
|
* сообщения — про минимум СБП, про потолок суммы, про неизвестную категорию —
|
||||||
|
* до водителя не доходили: экран просто не реагировал.
|
||||||
|
*/
|
||||||
|
export async function apiErrorText(err: unknown, fallback: string): Promise<string> {
|
||||||
|
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 () =>
|
export const tgSession = async () =>
|
||||||
TgSessionSchema.parse(await api.post("driver/auth/tg/session").json());
|
TgSessionSchema.parse(await api.post("driver/auth/tg/session").json());
|
||||||
export const tgPoll = async (pairSession: string) =>
|
/** POST, а не GET: в query-параметре pair_session оседал в access-логах. */
|
||||||
TgPollSchema.parse(await api.get("driver/auth/tg/poll", { searchParams: { pair_session: pairSession } }).json());
|
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) =>
|
export const authRequest = async (phone: string) =>
|
||||||
AuthRequestSchema.parse(await api.post("driver/auth/request", { json: { phone } }).json());
|
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());
|
VerifySchema.parse(await api.post("driver/auth/verify", { json: { phone, code } }).json());
|
||||||
export const getBalance = async () =>
|
export const getBalance = async () =>
|
||||||
BalanceSchema.parse(await api.get("driver/balance").json());
|
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") =>
|
export const topup = async (bucket: string, amount: number, method: "qr" | "card") =>
|
||||||
TopupSchema.parse(await api.post("driver/topup", { json: { bucket, amount, method } }).json());
|
TopupSchema.parse(await api.post("driver/topup", { json: { bucket, amount, method } }).json());
|
||||||
export const getPaymentState = async (orderId: string) =>
|
export const getPaymentState = async (orderId: string) =>
|
||||||
|
|||||||
@@ -10,20 +10,34 @@ beforeEach(() => { useAuth.getState().clear(); vi.restoreAllMocks(); });
|
|||||||
describe("usePairPoll", () => {
|
describe("usePairPoll", () => {
|
||||||
it("sets session on authenticated", async () => {
|
it("sets session on authenticated", async () => {
|
||||||
vi.spyOn(driver, "tgPoll").mockResolvedValue({ status: "authenticated", token: "T", driver_id: 7 } as any);
|
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"));
|
await waitFor(() => expect(result.current.status).toBe("authenticated"));
|
||||||
expect(useAuth.getState().token).toBe("T");
|
expect(useAuth.getState().token).toBe("T");
|
||||||
expect(useAuth.getState().driverId).toBe(7);
|
expect(useAuth.getState().driverId).toBe(7);
|
||||||
});
|
});
|
||||||
it("stays pending then expired", async () => {
|
it("stays pending then expired", async () => {
|
||||||
vi.spyOn(driver, "tgPoll").mockResolvedValue({ status: "expired" } as any);
|
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"));
|
await waitFor(() => expect(result.current.status).toBe("expired"));
|
||||||
});
|
});
|
||||||
it("does nothing when session is null", () => {
|
it("does nothing when session is null", () => {
|
||||||
const spy = vi.spyOn(driver, "tgPoll");
|
const spy = vi.spyOn(driver, "tgPoll");
|
||||||
const { result } = renderHook(() => usePairPoll(null));
|
const { result } = renderHook(() => usePairPoll(null, "SEC"));
|
||||||
expect(result.current.status).toBe("pending");
|
expect(result.current.status).toBe("pending");
|
||||||
expect(spy).not.toHaveBeenCalled();
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,13 +7,19 @@ type Status = "pending" | "authenticated" | "expired";
|
|||||||
const INTERVAL_MS = 2000;
|
const INTERVAL_MS = 2000;
|
||||||
const TIMEOUT_MS = 5 * 60 * 1000;
|
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<Status>("pending");
|
const [status, setStatus] = useState<Status>("pending");
|
||||||
const setSession = useAuth((s) => s.setSession);
|
const setSession = useAuth((s) => s.setSession);
|
||||||
const startedAt = useRef<number>(0);
|
const startedAt = useRef<number>(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pairSession) return;
|
if (!pairSession || !pollSecret) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
let timer: ReturnType<typeof setTimeout>;
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
startedAt.current = Date.now();
|
startedAt.current = Date.now();
|
||||||
@@ -21,7 +27,7 @@ export function usePairPoll(pairSession: string | null): { status: Status } {
|
|||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
try {
|
try {
|
||||||
const r = await tgPoll(pairSession);
|
const r = await tgPoll(pairSession, pollSecret);
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
if (r.status === "authenticated") {
|
if (r.status === "authenticated") {
|
||||||
setSession(r.token, r.driver_id);
|
setSession(r.token, r.driver_id);
|
||||||
@@ -37,7 +43,7 @@ export function usePairPoll(pairSession: string | null): { status: Status } {
|
|||||||
};
|
};
|
||||||
tick();
|
tick();
|
||||||
return () => { alive = false; clearTimeout(timer); };
|
return () => { alive = false; clearTimeout(timer); };
|
||||||
}, [pairSession, setSession]);
|
}, [pairSession, pollSecret, setSession]);
|
||||||
|
|
||||||
return { status };
|
return { status };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { keypadReduce, withCommission } from "./amount";
|
import { keypadReduce, parseAmount, withCommission } from "./amount";
|
||||||
|
|
||||||
describe("amount keypad", () => {
|
describe("amount keypad", () => {
|
||||||
it("appends digits, ignores leading zero, caps length", () => {
|
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(1000, 0.052)).toEqual({ commission: 52, total: 1052 });
|
||||||
expect(withCommission(333, 0.052)).toEqual({ commission: 17.32, total: 350.32 });
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
export function keypadReduce(current: string, key: string): string {
|
||||||
if (key === "back") return current.slice(0, -1);
|
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;
|
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 === "0") return key === "0" ? "0" : key; // replace leading zero
|
||||||
if (current === "" && key === "0") return ""; // no leading zero from empty
|
if (current === "" && key === "0") return ""; // no leading zero from empty
|
||||||
if (current.length >= MAX_DIGITS) return current;
|
if (current.length >= MAX_DIGITS) return current;
|
||||||
return current + key;
|
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 } {
|
export function withCommission(base: number, rate: number): { commission: number; total: number } {
|
||||||
const commission = Math.round(base * rate * 100) / 100;
|
const commission = Math.round(base * rate * 100) / 100;
|
||||||
const total = Math.round((base + commission) * 100) / 100;
|
const total = Math.round((base + commission) * 100) / 100;
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ describe("format", () => {
|
|||||||
expect(formatMoney(-3200)).toBe("−3 200 ₽");
|
expect(formatMoney(-3200)).toBe("−3 200 ₽");
|
||||||
expect(formatMoney(0)).toBe("0 ₽");
|
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", () => {
|
it("digitsOnly strips non-digits", () => {
|
||||||
expect(digitsOnly("+7 (914) 305-44-00")).toBe("79143054400");
|
expect(digitsOnly("+7 (914) 305-44-00")).toBe("79143054400");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,22 @@ export function digitsOnly(s: string): string {
|
|||||||
return (s || "").replace(/\D/g, "");
|
return (s || "").replace(/\D/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Деньги для показа. Копейки печатаем ТОЛЬКО когда они есть.
|
||||||
|
*
|
||||||
|
* Раньше здесь было Math.round(), и водителю показывали одну сумму, а банк
|
||||||
|
* списывал другую: комиссия 5.2% почти всегда даёт копейки (1000 → 1052,00, но
|
||||||
|
* 333 → 350,32), и «к оплате 350 ₽» расходилось и со списанием, и с чеком.
|
||||||
|
*/
|
||||||
export function formatMoney(rub: number): string {
|
export function formatMoney(rub: number): string {
|
||||||
const neg = rub < 0;
|
const neg = rub < 0;
|
||||||
const abs = Math.abs(Math.round(rub));
|
const abs = Math.abs(rub);
|
||||||
const grouped = abs.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
const kop = Math.round(abs * 100);
|
||||||
return `${neg ? "−" : ""}${grouped} ₽`;
|
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. */
|
/** Render a Russian mobile from up-to-10 national digits as +7 914 305-44-00. */
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ export function BalancePage() {
|
|||||||
|
|
||||||
const accounts = data?.accounts ?? [];
|
const accounts = data?.accounts ?? [];
|
||||||
const total = accounts.reduce((s, a) => s + a.balance, 0);
|
const total = accounts.reduce((s, a) => s + a.balance, 0);
|
||||||
|
const pending = data?.pending_topup ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -21,6 +22,14 @@ export function BalancePage() {
|
|||||||
<div className="card p-4 mb-4 text-center">
|
<div className="card p-4 mb-4 text-center">
|
||||||
<p className="text-muted text-xs mb-1">Баланс</p>
|
<p className="text-muted text-xs mb-1">Баланс</p>
|
||||||
<p className={`text-2xl font-extrabold ${total < 0 ? "text-neg" : "text-pos"}`}>{formatMoney(total)}</p>
|
<p className={`text-2xl font-extrabold ${total < 0 ? "text-neg" : "text-pos"}`}>{formatMoney(total)}</p>
|
||||||
|
{pending > 0 && (
|
||||||
|
// Платёж принят нами, но 1С его ещё не провела. В сумму баланса он не
|
||||||
|
// входит намеренно: сложить оба источника значило бы показать
|
||||||
|
// пополнение дважды, как только расчётный лист принесёт тот же платёж.
|
||||||
|
<p className="text-muted text-xs mt-2">
|
||||||
|
{formatMoney(pending)} зачисляется — учтётся после проведения
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|||||||
@@ -4,10 +4,30 @@ import { getPayments } from "@/api/driver";
|
|||||||
import { formatMoney } from "@/lib/format";
|
import { formatMoney } from "@/lib/format";
|
||||||
import { Spinner } from "@/components/Spinner";
|
import { Spinner } from "@/components/Spinner";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Все статусы, которые может прислать бэкенд. Раньше их было пять из тринадцати,
|
||||||
|
* и водитель видел в истории «DEADLINE_EXPIRED» или «ATTEMPTS_EXPIRED» латиницей,
|
||||||
|
* а возврат денег выглядел ровно как обычный отказ.
|
||||||
|
*/
|
||||||
const STATUS_RU: Record<string, string> = {
|
const STATUS_RU: Record<string, string> = {
|
||||||
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() {
|
export function HistoryPage() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const { data, isLoading, isError } = useQuery({ queryKey: ["payments"], queryFn: getPayments });
|
const { data, isLoading, isError } = useQuery({ queryKey: ["payments"], queryFn: getPayments });
|
||||||
@@ -24,17 +44,23 @@ export function HistoryPage() {
|
|||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<p className="text-muted text-sm">Пока нет пополнений</p>
|
<p className="text-muted text-sm">Пока нет пополнений</p>
|
||||||
) : (
|
) : (
|
||||||
rows.map((p) => (
|
rows.map((p) => {
|
||||||
|
const returned = RETURNED.has(p.status) || !!p.reversed_at;
|
||||||
|
return (
|
||||||
<div key={p.order_id} className="flex justify-between items-center py-3 border-b border-line">
|
<div key={p.order_id} className="flex justify-between items-center py-3 border-b border-line">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm">{p.bucket}</p>
|
<p className="text-sm">{p.bucket}</p>
|
||||||
<p className="text-muted text-xs">
|
<p className="text-muted text-xs">
|
||||||
{p.created_at ? new Date(p.created_at).toLocaleDateString("ru-RU") : ""} · {STATUS_RU[p.status] ?? p.status}
|
{p.created_at ? new Date(p.created_at).toLocaleDateString("ru-RU") : ""} ·{" "}
|
||||||
|
{STATUS_RU[p.status] ?? p.status}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<b>{formatMoney(p.amount)}</b>
|
<b className={returned ? "text-muted line-through" : undefined}>
|
||||||
|
{formatMoney(p.amount)}
|
||||||
|
</b>
|
||||||
</div>
|
</div>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
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 { digitsOnly, formatPhone } from "@/lib/format";
|
||||||
|
import { savePairing } from "@/lib/pairing";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
import { Spinner } from "@/components/Spinner";
|
import { Spinner } from "@/components/Spinner";
|
||||||
|
|
||||||
@@ -18,7 +19,8 @@ export function LoginPage() {
|
|||||||
// Prefetched pairing session so the «Войти через Telegram» control is a real
|
// Prefetched pairing session so the «Войти через Telegram» control is a real
|
||||||
// <a href> the OS opens in Telegram (a synchronous user gesture) WITHOUT
|
// <a href> the OS opens in Telegram (a synchronous user gesture) WITHOUT
|
||||||
// navigating the PWA away — the app stays mounted on /tg-wait and polls.
|
// 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;
|
const e164 = "7" + phone;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -29,8 +31,7 @@ export function LoginPage() {
|
|||||||
|
|
||||||
function startTg() {
|
function startTg() {
|
||||||
if (!tg) return;
|
if (!tg) return;
|
||||||
sessionStorage.setItem("pp-pair-session", tg.pair_session);
|
savePairing(tg);
|
||||||
if (tg.deep_link) sessionStorage.setItem("pp-pair-deeplink", tg.deep_link);
|
|
||||||
nav("/tg-wait");
|
nav("/tg-wait");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +41,10 @@ export function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
const r = await authRequest(e164);
|
const r = await authRequest(e164);
|
||||||
if (r.status === "onboarding") {
|
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 }));
|
sessionStorage.setItem("pp-onboarding", JSON.stringify({ ...r.deep_links, phone }));
|
||||||
nav("/onboarding");
|
nav("/onboarding");
|
||||||
return;
|
return;
|
||||||
@@ -48,11 +53,11 @@ export function LoginPage() {
|
|||||||
setStep("code");
|
setStep("code");
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const status = (err as { response?: { status?: number } })?.response?.status;
|
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||||
const msg =
|
const fallback =
|
||||||
status === 404 ? "Номер не найден. Обратитесь в парк." :
|
status === 404 ? "Номер не найден. Обратитесь в парк." :
|
||||||
status === 429 ? "Слишком часто. Попробуйте позже." :
|
status === 429 ? "Слишком часто. Попробуйте позже." :
|
||||||
"Не удалось отправить код";
|
"Не удалось отправить код";
|
||||||
toast.error(msg);
|
toast.error(await apiErrorText(err, fallback));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -65,8 +70,8 @@ export function LoginPage() {
|
|||||||
const r = await authVerify(e164, code);
|
const r = await authVerify(e164, code);
|
||||||
setSession(r.token, r.driver_id);
|
setSession(r.token, r.driver_id);
|
||||||
nav("/");
|
nav("/");
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
toast.error("Неверный или просроченный код");
|
toast.error(await apiErrorText(err, "Неверный или просроченный код"));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,41 @@ import { describe, it, expect, beforeEach } from "vitest";
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { OnboardingPage } from "./OnboardingPage";
|
import { OnboardingPage } from "./OnboardingPage";
|
||||||
|
import { savePairing, clearPairing } from "@/lib/pairing";
|
||||||
|
|
||||||
describe("OnboardingPage", () => {
|
describe("OnboardingPage", () => {
|
||||||
beforeEach(() =>
|
beforeEach(() => {
|
||||||
sessionStorage.setItem("pp-onboarding", JSON.stringify({ tg: "https://t.me/b?start=x", max: "https://max.ru/b?start=x", phone: "9143054400" }))
|
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-code action", () => {
|
});
|
||||||
|
|
||||||
|
it("shows both bot links + back to login", () => {
|
||||||
render(<MemoryRouter><OnboardingPage /></MemoryRouter>);
|
render(<MemoryRouter><OnboardingPage /></MemoryRouter>);
|
||||||
expect(screen.getByText(/Подключите бот/i)).toBeInTheDocument();
|
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: /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("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", () => {
|
it("renders only Telegram when max is absent", () => {
|
||||||
sessionStorage.setItem("pp-onboarding", JSON.stringify({ tg: "https://t.me/ppdriver_bot?start=x", phone: "9143054400" }));
|
sessionStorage.setItem("pp-onboarding", JSON.stringify({ tg: "https://t.me/ppdriver_bot?start=x", phone: "9143054400" }));
|
||||||
render(<MemoryRouter><OnboardingPage /></MemoryRouter>);
|
render(<MemoryRouter><OnboardingPage /></MemoryRouter>);
|
||||||
expect(screen.getByRole("link", { name: /Telegram/i })).toBeInTheDocument();
|
expect(screen.getByRole("link", { name: /Telegram/i })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("link", { name: /MAX/i })).toBeNull();
|
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(<MemoryRouter><OnboardingPage /></MemoryRouter>);
|
||||||
|
expect(screen.getByText("4217")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no longer asks the driver to come back for a code", () => {
|
||||||
|
// Онбординг идёт той же pairing-сессией: Telegram подтверждает номер, и
|
||||||
|
// приложение забирает токен опросом — второй раз номер вводить не нужно.
|
||||||
|
render(<MemoryRouter><OnboardingPage /></MemoryRouter>);
|
||||||
|
expect(screen.queryByRole("button", { name: /запросить код/i })).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +1,37 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { loadPairing } from "@/lib/pairing";
|
||||||
|
|
||||||
export function OnboardingPage() {
|
export function OnboardingPage() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const raw = sessionStorage.getItem("pp-onboarding");
|
const raw = sessionStorage.getItem("pp-onboarding");
|
||||||
const data = raw ? (JSON.parse(raw) as { tg?: string; max?: string; phone: string }) : null;
|
const data = raw ? (JSON.parse(raw) as { tg?: string; max?: string; phone: string }) : null;
|
||||||
|
const pairing = loadPairing();
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-16">
|
<div className="pt-16">
|
||||||
<h1 className="text-xl font-extrabold mb-1">Подключите бот</h1>
|
<h1 className="text-xl font-extrabold mb-1">Подключите бот</h1>
|
||||||
<p className="text-muted text-sm mb-8">
|
<p className="text-muted text-sm mb-8">
|
||||||
Чтобы получать код входа, откройте бот и нажмите «Старт». Затем вернитесь и запросите код снова.
|
Откройте бот и поделитесь своим номером — Telegram подтвердит его, и вход
|
||||||
|
произойдёт автоматически. Возвращаться за кодом не нужно.
|
||||||
</p>
|
</p>
|
||||||
|
{pairing?.confirmCode && (
|
||||||
|
<div className="card p-4 mb-6 text-center">
|
||||||
|
<p className="text-muted text-xs mb-1">Код подтверждения</p>
|
||||||
|
<p className="text-2xl font-extrabold tracking-widest">{pairing.confirmCode}</p>
|
||||||
|
<p className="text-muted text-xs mt-2">
|
||||||
|
Бот назовёт это число. Не совпало — закройте бот и не делитесь номером.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{data.tg && (
|
{data.tg && (
|
||||||
<a href={data.tg} target="_blank" rel="noopener" className="btn-primary mb-3 no-underline block">
|
<a
|
||||||
|
href={data.tg}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
className="btn-primary mb-3 no-underline block"
|
||||||
|
onClick={() => nav("/tg-wait")}
|
||||||
|
>
|
||||||
Подключить Telegram
|
Подключить Telegram
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
@@ -23,7 +41,7 @@ export function OnboardingPage() {
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<button className="btn-ghost" onClick={() => nav("/login")}>
|
<button className="btn-ghost" onClick={() => nav("/login")}>
|
||||||
Я подключил — запросить код
|
Назад ко входу
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,27 +1,25 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { usePairPoll } from "@/hooks/usePairPoll";
|
import { usePairPoll } from "@/hooks/usePairPoll";
|
||||||
|
import { clearPairing, loadPairing } from "@/lib/pairing";
|
||||||
import { Spinner } from "@/components/Spinner";
|
import { Spinner } from "@/components/Spinner";
|
||||||
|
|
||||||
export function TgWaitPage() {
|
export function TgWaitPage() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const pairSession = sessionStorage.getItem("pp-pair-session");
|
const pairing = loadPairing();
|
||||||
const deepLink = sessionStorage.getItem("pp-pair-deeplink");
|
const { status } = usePairPoll(pairing?.pairSession ?? null, pairing?.pollSecret ?? null);
|
||||||
const { status } = usePairPoll(pairSession);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pairSession) { nav("/login", { replace: true }); }
|
if (!pairing) { nav("/login", { replace: true }); }
|
||||||
}, [pairSession, nav]);
|
}, [pairing, nav]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === "authenticated") {
|
if (status === "authenticated") {
|
||||||
sessionStorage.removeItem("pp-pair-session");
|
clearPairing();
|
||||||
sessionStorage.removeItem("pp-pair-deeplink");
|
|
||||||
nav("/", { replace: true });
|
nav("/", { replace: true });
|
||||||
}
|
}
|
||||||
if (status === "expired") {
|
if (status === "expired") {
|
||||||
sessionStorage.removeItem("pp-pair-session");
|
clearPairing();
|
||||||
sessionStorage.removeItem("pp-pair-deeplink");
|
|
||||||
nav("/login", { replace: true, state: { expired: true } });
|
nav("/login", { replace: true, state: { expired: true } });
|
||||||
}
|
}
|
||||||
}, [status, nav]);
|
}, [status, nav]);
|
||||||
@@ -30,10 +28,24 @@ export function TgWaitPage() {
|
|||||||
<div className="pt-16 text-center">
|
<div className="pt-16 text-center">
|
||||||
<h1 className="text-xl font-extrabold mb-2">Премиум Водитель</h1>
|
<h1 className="text-xl font-extrabold mb-2">Премиум Водитель</h1>
|
||||||
<p className="text-muted text-sm mb-6">
|
<p className="text-muted text-sm mb-6">
|
||||||
В Telegram нажмите «Поделиться номером для входа», затем вернитесь сюда — вход произойдёт автоматически.
|
Бот назовёт число ниже и попросит подтвердить вход — нажмите «Это я, войти»
|
||||||
|
(или поделитесь номером, если входите впервые). Затем вернитесь сюда, вход
|
||||||
|
произойдёт автоматически.
|
||||||
</p>
|
</p>
|
||||||
{deepLink && (
|
{pairing?.confirmCode && (
|
||||||
<a href={deepLink} target="_blank" rel="noopener noreferrer" className="btn-primary block text-center mb-6">
|
// Число сверки: бот называет его в своём сообщении. Если человек попал в
|
||||||
|
// бот по чужой ссылке, у него на экране этого числа нет — подтверждать
|
||||||
|
// нечего, и токен не выдаётся.
|
||||||
|
<div className="card p-4 mb-6">
|
||||||
|
<p className="text-muted text-xs mb-1">Код подтверждения</p>
|
||||||
|
<p className="text-2xl font-extrabold tracking-widest">{pairing.confirmCode}</p>
|
||||||
|
<p className="text-muted text-xs mt-2">
|
||||||
|
Это же число должен назвать бот. Не совпало — нажмите в боте «Это не я».
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pairing?.deepLink && (
|
||||||
|
<a href={pairing.deepLink} target="_blank" rel="noopener noreferrer" className="btn-primary block text-center mb-6">
|
||||||
Открыть Telegram
|
Открыть Telegram
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,12 +7,16 @@ 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 })),
|
||||||
getCommission: vi.fn(async () => ({ rate: 0.052 })),
|
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 { TopupPage } from "./TopupPage";
|
||||||
import * as driver from "@/api/driver";
|
import * as driver from "@/api/driver";
|
||||||
|
|
||||||
const wrap = () => {
|
const wrap = () => {
|
||||||
const qc = new QueryClient();
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
<MemoryRouter><TopupPage /></MemoryRouter>
|
<MemoryRouter><TopupPage /></MemoryRouter>
|
||||||
@@ -32,4 +36,27 @@ describe("TopupPage", () => {
|
|||||||
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");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,41 +3,49 @@ import { useLocation, useNavigate } from "react-router-dom";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Delete } from "lucide-react";
|
import { Delete } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { topup, getCommission } from "@/api/driver";
|
import { apiErrorText, topup, getCommission, getBuckets } from "@/api/driver";
|
||||||
import { keypadReduce, withCommission } from "@/lib/amount";
|
import { keypadReduce, parseAmount, withCommission } from "@/lib/amount";
|
||||||
import { formatMoney } from "@/lib/format";
|
import { formatMoney } from "@/lib/format";
|
||||||
import { Segment } from "@/components/Segment";
|
import { Segment } from "@/components/Segment";
|
||||||
import { Spinner } from "@/components/Spinner";
|
import { Spinner } from "@/components/Spinner";
|
||||||
|
|
||||||
const QUICK = ["500", "1000", "2000"];
|
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() {
|
export function TopupPage() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const loc = useLocation();
|
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<string | null>(null);
|
||||||
|
const bucket = picked ?? fromBalance ?? bucketData?.with_debt[0] ?? options[0] ?? "";
|
||||||
|
|
||||||
const [raw, setRaw] = useState("");
|
const [raw, setRaw] = useState("");
|
||||||
const [method, setMethod] = useState("qr");
|
const [method, setMethod] = useState("qr");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const { data: commission } = useQuery({ queryKey: ["commission"], queryFn: getCommission });
|
const { data: commission } = useQuery({ queryKey: ["commission"], queryFn: getCommission });
|
||||||
const rate = commission?.rate ?? 0.052; // fallback only while loading
|
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);
|
const { commission: fee, total } = withCommission(base, rate);
|
||||||
|
|
||||||
function press(k: string) {
|
function press(k: string) {
|
||||||
if (k === "·") return;
|
|
||||||
setRaw((r) => keypadReduce(r, k));
|
setRaw((r) => keypadReduce(r, k));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pay() {
|
async function pay() {
|
||||||
if (base <= 0) 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");
|
||||||
nav(`/pay/${r.order_id}`, { state: { payUrl: r.pay_url, total: r.total, bucket, base } });
|
nav(`/pay/${r.order_id}`, { state: { payUrl: r.pay_url, total: r.total, bucket, base } });
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
toast.error("Не удалось создать платёж");
|
// Текст с сервера, а не глухое «не удалось»: минимум СБП, потолок суммы и
|
||||||
|
// неизвестная категория объясняются словами, иначе экран молча не реагирует.
|
||||||
|
toast.error(await apiErrorText(err, "Не удалось создать платёж"));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -45,7 +53,24 @@ export function TopupPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<button className="text-muted text-xs mb-3" onClick={() => nav(-1)}>‹ {bucket}</button>
|
<button className="text-muted text-xs mb-3" onClick={() => nav(-1)}>‹ Назад</button>
|
||||||
|
|
||||||
|
{options.length > 1 && (
|
||||||
|
<div className="flex gap-1.5 mb-3 overflow-x-auto">
|
||||||
|
{options.map((b) => (
|
||||||
|
<button
|
||||||
|
key={b}
|
||||||
|
onClick={() => setPicked(b)}
|
||||||
|
className={`shrink-0 px-3 py-2 rounded-xl2 text-xs border ${
|
||||||
|
b === bucket ? "border-line bg-surface font-semibold" : "border-line text-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{b}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<p data-testid="amount" className="text-3xl font-extrabold text-center mt-2">{formatMoney(base)}</p>
|
<p data-testid="amount" className="text-3xl font-extrabold text-center mt-2">{formatMoney(base)}</p>
|
||||||
<p data-testid="total" className="text-muted text-xs text-center mb-3">
|
<p data-testid="total" className="text-muted text-xs text-center mb-3">
|
||||||
к оплате {formatMoney(total)} (комиссия {formatMoney(fee)})
|
к оплате {formatMoney(total)} (комиссия {formatMoney(fee)})
|
||||||
@@ -76,7 +101,7 @@ 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>
|
||||||
|
|
||||||
<button className="btn-primary" disabled={busy || base <= 0} onClick={pay}>
|
<button className="btn-primary" disabled={busy || base <= 0 || !bucket} onClick={pay}>
|
||||||
{busy ? <Spinner /> : "Оплатить"}
|
{busy ? <Spinner /> : "Оплатить"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user