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:
2026-08-24 20:38:49 +10:00
co-authored by Claude Fable 5
parent 80ac25ee0f
commit 0c433280fe
5 changed files with 96 additions and 3 deletions
@@ -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. Показываем её.
+17 -1
View File
@@ -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) {
// Текст с сервера, а не глухое «не удалось»: минимум СБП, потолок суммы и