balance: экран опрашивает себя после оплаты — итог обновляется на глазах
Первая обратная связь: «очень долго обновляется баланс». Экран показывал старый итог, пока приложение не перезапустят: перезапроса по расписанию не было вовсе. - баланс опрашивается каждые 4 сек, пока висит «Зачисляется» ЛИБО в последние 5 минут была платёжная активность (lib/paymentWatch, отметка в sessionStorage — переживает перезагрузку страницы при возврате из банка) - отметка ставится при создании платежа (TopupPage) и при факте оплаты (PaymentPage): опрос успевает застать появление «Зачисляется» при запаздывающем вебхуке и не гаснет раньше, чем снимок баланса догонит платёж при сбое точечного обновления на бэке Парная правка бэкенда — crm2 b64ea35 (запись в 1С каждые 15 сек + точечный снимок баланса). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Отметка «водитель только что платил» — общая для экранов пополнения и баланса.
|
||||
*
|
||||
* Зачем она нужна, если экран баланса и так опрашивает себя, пока висит
|
||||
* «Зачисляется» (pending_topup > 0). У опроса по одному этому признаку два
|
||||
* слепых пятна, и оба приходятся ровно на сбойные сценарии:
|
||||
*
|
||||
* 1. Водитель ушёл с экрана оплаты жестом «назад» ДО прихода вебхука банка
|
||||
* (СБП подтверждается и через 10–60 секунд). Баланс на тот момент отвечает
|
||||
* pending_topup=0 — опрос не взводится, и когда вебхук наконец ставит PAID,
|
||||
* перезапросить баланс уже некому: экран оплаты размонтирован вместе со
|
||||
* своим опросом. Старый итог висит без строки «Зачисляется» бессрочно.
|
||||
*
|
||||
* 2. «Зачисляется» гаснет в момент записи платежа в 1С, и в норме итог в том же
|
||||
* ответе уже новый. Но точечное обновление снимка — не гарантия (1С может
|
||||
* не ответить, тогда снимок догонит общий синк за ≤2 минуты): опрос,
|
||||
* выключившийся по pending_topup=0, этот догон уже не увидит — деньги
|
||||
* «пропадают» с экрана до смены фокуса.
|
||||
*
|
||||
* Поэтому платёжная активность (создание платежа, факт оплаты) оставляет
|
||||
* отметку, и баланс продолжает опрашиваться ещё WATCH_MS после неё — окно с
|
||||
* запасом накрывает и лаг вебхука, и полный цикл общего синка.
|
||||
*
|
||||
* sessionStorage, а не переменная модуля: PWA на телефоне перезагружает
|
||||
* страницу охотно (нехватка памяти, возврат из банковского приложения), и
|
||||
* отметка в памяти не пережила бы ровно тот переход, ради которого заведена.
|
||||
*/
|
||||
|
||||
const KEY = "pp-payment-activity-at";
|
||||
|
||||
/** Окно опроса после платёжной активности: лаг вебхука (до минуты) + запись в
|
||||
* 1С (≤15 с) + общий синк снимка (≤2 мин) — с запасом. */
|
||||
export const WATCH_MS = 5 * 60_000;
|
||||
|
||||
export function markPaymentActivity(): void {
|
||||
try {
|
||||
sessionStorage.setItem(KEY, String(Date.now()));
|
||||
} catch {
|
||||
// Приватный режим или отключённое хранилище: без отметки остаётся опрос по
|
||||
// «Зачисляется» — деградация до прежнего поведения, а не поломка.
|
||||
}
|
||||
}
|
||||
|
||||
export function paymentActivityWithin(ms: number = WATCH_MS): boolean {
|
||||
try {
|
||||
const t = Number(sessionStorage.getItem(KEY) || 0);
|
||||
return t > 0 && Date.now() - t < ms;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,8 @@ vi.mock("@/api/driver", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
import { BalancePage } from "./BalancePage";
|
||||
import { BalancePage, balancePollMs } from "./BalancePage";
|
||||
import { markPaymentActivity } from "@/lib/paymentWatch";
|
||||
import * as driver from "@/api/driver";
|
||||
|
||||
function wrap() {
|
||||
@@ -56,6 +57,21 @@ function wrap() {
|
||||
}
|
||||
|
||||
describe("главный экран", () => {
|
||||
it("опрос баланса: пока висит «зачисляется» или водитель недавно платил", () => {
|
||||
// Водитель после оплаты смотрит на экран и ждёт новый итог — экран обязан
|
||||
// обновиться сам. Вне платёжных окон опроса быть не должно.
|
||||
sessionStorage.clear();
|
||||
expect(balancePollMs({ pending_topup: 1500 })).toBe(4000);
|
||||
expect(balancePollMs({ pending_topup: 0 })).toBe(false);
|
||||
expect(balancePollMs(undefined)).toBe(false);
|
||||
// Свежая платёжная активность держит опрос и без «зачисляется»: вебхук
|
||||
// банка может запаздывать (опрос обязан застать появление строки), а снимок
|
||||
// баланса — догонять платёж общим синком уже после её погашения.
|
||||
markPaymentActivity();
|
||||
expect(balancePollMs({ pending_topup: 0 })).toBe(4000);
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it("сверху деньги: итог с сервера и строка «зачисляется»", async () => {
|
||||
wrap();
|
||||
// Сумма статей дала бы −8 006, а в CRM у водителя −8 976,25. Показываем её.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { ArrowUp, ChevronRight, LogOut } from "lucide-react";
|
||||
import { getBalance, type StatementKind } from "@/api/driver";
|
||||
import { formatMoney, isoDaysAgo, isoToday } from "@/lib/format";
|
||||
import { paymentActivityWithin } from "@/lib/paymentWatch";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { AppHeader } from "@/components/AppHeader";
|
||||
import { NotificationsPrompt } from "@/components/NotificationsPrompt";
|
||||
@@ -17,6 +18,17 @@ import { Statement } from "@/components/Statement";
|
||||
// шторки «Пополнить», иначе экран снова превращается в столбик из шести строк.
|
||||
const MAIN_BUCKETS = ["Долг аренда", "Долг по штрафам"];
|
||||
|
||||
// Пока висит «Зачисляется» ЛИБО водитель недавно платил, экран опрашивает
|
||||
// баланс сам: платёж доезжает до 1С за секунды, и новый итог должен появиться
|
||||
// на глазах — водитель после оплаты смотрит именно сюда, а перезапускать
|
||||
// приложение он не обязан. Одного «зачисляется» мало: опрос по нему не
|
||||
// взводится, пока вебхук банка запаздывает, и гаснет раньше, чем снимок
|
||||
// баланса догонит платёж при сбое точечного обновления (см. lib/paymentWatch).
|
||||
// Вне этих окон опрос не нужен: остальные движения баланса водитель не
|
||||
// караулит у экрана.
|
||||
export const balancePollMs = (d?: { pending_topup?: number }): number | false =>
|
||||
(d?.pending_topup ?? 0) > 0 || paymentActivityWithin() ? 4000 : false;
|
||||
|
||||
/**
|
||||
* Главный экран из двух половин: сверху деньги, снизу — за что они списаны.
|
||||
*
|
||||
@@ -27,7 +39,11 @@ const MAIN_BUCKETS = ["Долг аренда", "Долг по штрафам"];
|
||||
export function BalancePage() {
|
||||
const nav = useNavigate();
|
||||
const clear = useAuth((s) => s.clear);
|
||||
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["balance"], queryFn: getBalance });
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["balance"],
|
||||
queryFn: getBalance,
|
||||
refetchInterval: (q) => balancePollMs(q.state.data),
|
||||
});
|
||||
const [sheet, setSheet] = useState(false);
|
||||
// Неделя — тот же период, что у вендора: столько человек помнит свои поездки.
|
||||
const [from, setFrom] = useState(isoDaysAgo(7));
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Clock, Undo2, WifiOff, X } from "lucide-react";
|
||||
import { formatMoney } from "@/lib/format";
|
||||
import { markPaymentActivity } from "@/lib/paymentWatch";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import { usePaymentStatus } from "@/hooks/usePaymentStatus";
|
||||
|
||||
@@ -31,7 +32,12 @@ export function PaymentPage() {
|
||||
const card = st.method ? st.method === "card" : !!st.payUrl && !st.payUrl.includes("qr.nspk.ru");
|
||||
|
||||
useEffect(() => {
|
||||
if (paid) qc.invalidateQueries({ queryKey: ["balance"] });
|
||||
if (paid) {
|
||||
// Отметка продлевает окно опроса баланса от ФАКТА оплаты: путь до нового
|
||||
// итога (запись в 1С + снимок) начинается только теперь.
|
||||
markPaymentActivity();
|
||||
qc.invalidateQueries({ queryKey: ["balance"] });
|
||||
}
|
||||
}, [paid, qc]);
|
||||
|
||||
const retry = (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { toast } from "sonner";
|
||||
import { apiErrorText, topup, getCommission, getBuckets } from "@/api/driver";
|
||||
import { keypadReduce, parseAmount, withCommission } from "@/lib/amount";
|
||||
import { formatMoney } from "@/lib/format";
|
||||
import { markPaymentActivity } from "@/lib/paymentWatch";
|
||||
import { useStaff } from "@/store/staff";
|
||||
import { Segment } from "@/components/Segment";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
@@ -49,6 +50,9 @@ export function TopupPage() {
|
||||
nav(`/mock-pay/${r.order_id}`, { state: { total: r.total, bucket, base, method } });
|
||||
return;
|
||||
}
|
||||
// Отметка ДО ухода на оплату: вернись водитель на баланс жестом «назад»
|
||||
// раньше вебхука банка, экран обязан опрашивать себя и подхватить платёж.
|
||||
markPaymentActivity();
|
||||
nav(`/pay/${r.order_id}`, { state: { payUrl: r.pay_url, total: r.total, bucket, base, method } });
|
||||
} catch (err: unknown) {
|
||||
// Текст с сервера, а не глухое «не удалось»: минимум СБП, потолок суммы и
|
||||
|
||||
Reference in New Issue
Block a user