c0487ddaa5
Разбор живого клиента (стоматология, Красноярск, 14.07): 2 часа настраивал 26 проектов, получил 14 отказов в трёх формах и ушёл, не заплатив. Деньги: - отменённый шлюзом платёж больше не висит «ожидает» вечно: закрываем как failed с причиной (PaymentSettlementService — общий путь для webhook и крона); - billing:reconcile-payments каждые 5 минут сам спрашивает шлюз про зависшие pending. Побочно страхует от ПОТЕРИ ДЕНЕГ: если webhook не дойдёт, оплаченный платёж всё равно зачислится; - кабинет говорит правду: «Оплата не завершена» + «Оплатить снова» вместо «баланс обновится автоматически» (GET /api/billing/last-payment). 🔴 RLS-мина (поймана валидатором ДО выката): UPDATE при отмене шёл без tenant-контекста → на проде тронул бы 0 строк, а портал рапортовал бы «отменено». Тесты слепы (тестовая БД под postgres). Регресс-тест проверяет ПОРЯДОК: SET LOCAL tenant ДО UPDATE. Тот же класс, что инциденты 07.07 и 12.07. Формы (клиент бился и уходил): - удаление проекта со сделками: причина показывается на месте + кнопка «Поставить на паузу» (раньше 422 улетал в никуда — 4 попытки впустую); - создание проекта: ошибка по дням недели больше не молчит (у поля не было места для показа — 2 немых отказа); - автоподбор «Добавить вручную»: показываем причину от сервера (был голый catch {}), длинные ссылки 2ГИС/Яндекс.Карт принимаются — трекинг-хвост срезаем сами. Воспроизведено тестом: именно длинная ссылка давала 3 отказа подряд. Наблюдаемость: причины отказов пишутся в журнал (маршрут, tenant, ИМЕНА полей; значений нет — 152-ФЗ). Уровень warning: на проде LOG_LEVEL=warning, info в журнал не попадает вовсе. Робот-сверщик добавлен в реестр пульса. Тесты: Pest 2475/2475, Vitest 1215/1215. Выкачено на боевой 14.07.2026 ~13:00 МСК; сверка сразу закрыла 3 мёртвых платежа (10 000 ₽, 5 000 ₽, 1 000 ₽). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
184 lines
8.0 KiB
TypeScript
184 lines
8.0 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { mount, flushPromises } from '@vue/test-utils';
|
|
import { createPinia, setActivePinia } from 'pinia';
|
|
import { createVuetify } from 'vuetify';
|
|
import BillingView from '../../resources/js/views/BillingView.vue';
|
|
import * as billingApi from '../../resources/js/api/billing';
|
|
import type { Wallet } from '../../resources/js/api/billing';
|
|
|
|
vi.mock('../../resources/js/api/billing');
|
|
|
|
const vuetify = createVuetify();
|
|
|
|
function makeWallet(): Wallet {
|
|
return {
|
|
balance_rub: '14250.00',
|
|
affordable_leads: 28,
|
|
current_tier: { no: 1, price_rub: '500.00', leads_left_in_tier: 100 },
|
|
next_tier: { no: 2, price_rub: '450.00', leads_in_tier: 200 },
|
|
delivered_in_month: 0,
|
|
runway_days: 142,
|
|
tiers_preview: [
|
|
{ tier_no: 1, price_rub: '500.00', leads_in_tier: 100 },
|
|
{ tier_no: 2, price_rub: '450.00', leads_in_tier: 200 },
|
|
],
|
|
tariff: {
|
|
code: 'pro',
|
|
name: 'Про',
|
|
features: ['webhook', 'kanban', 'api'],
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('BillingView.vue', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createPinia());
|
|
vi.mocked(billingApi.getWallet).mockResolvedValue(makeWallet());
|
|
vi.mocked(billingApi.getLastPayment).mockResolvedValue(null); // по умолчанию незавершённых оплат нет
|
|
});
|
|
|
|
const factory = () =>
|
|
mount(BillingView, {
|
|
global: {
|
|
plugins: [vuetify, createPinia()],
|
|
stubs: { TransactionsTable: true, InvoicesTable: true, ChargesTab: true, TopupDialog: true },
|
|
},
|
|
});
|
|
|
|
it('монтируется с заголовком «Биллинг и тарифы»', async () => {
|
|
const wrapper = factory();
|
|
await flushPromises();
|
|
expect(wrapper.find('h1').text()).toBe('Биллинг и тарифы');
|
|
});
|
|
|
|
it('загружает кошелёк и показывает баланс в шапке', async () => {
|
|
const wrapper = factory();
|
|
await flushPromises();
|
|
expect(billingApi.getWallet).toHaveBeenCalled();
|
|
const text = wrapper.text();
|
|
expect(text).toMatch(/14\s+250\s*₽/);
|
|
});
|
|
|
|
it('не показывает чип «лидов запас» в шапке', async () => {
|
|
const wrapper = factory();
|
|
await flushPromises();
|
|
expect(wrapper.text()).not.toContain('лидов запас');
|
|
});
|
|
|
|
it('показывает «Запас» в BalanceCard вместо карточки «Тариф»', async () => {
|
|
// tenant-store в тесте не замокан → requiredLeadsPerDay = 0 → нет активных проектов.
|
|
// B1-2 (25.06): было «∞», теперь «—» + «нет активных проектов» (как на дашборде).
|
|
const wrapper = factory();
|
|
await flushPromises();
|
|
const text = wrapper.text();
|
|
expect(text).toContain('Запас');
|
|
expect(text).toContain('нет активных проектов');
|
|
expect(text).not.toContain('∞');
|
|
expect(text).not.toContain('Тариф не выбран');
|
|
expect(text).not.toContain('по тарифу');
|
|
});
|
|
|
|
it('показывает error-alert при сбое загрузки кошелька', async () => {
|
|
vi.mocked(billingApi.getWallet).mockRejectedValue(new Error('network'));
|
|
const wrapper = factory();
|
|
await flushPromises();
|
|
expect(wrapper.text()).toContain('Не удалось загрузить');
|
|
});
|
|
|
|
it('кнопка «Повторить» перезагружает кошелёк после ошибки', async () => {
|
|
vi.mocked(billingApi.getWallet).mockReset();
|
|
vi.mocked(billingApi.getWallet).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce(makeWallet());
|
|
const wrapper = factory();
|
|
await flushPromises();
|
|
expect(wrapper.text()).toContain('Не удалось загрузить');
|
|
|
|
const retry = wrapper.findAll('button').find((b) => b.text().includes('Повторить'));
|
|
expect(retry).toBeDefined();
|
|
await retry!.trigger('click');
|
|
await flushPromises();
|
|
|
|
expect(billingApi.getWallet).toHaveBeenCalledTimes(2);
|
|
expect(wrapper.text()).toMatch(/14\s+250\s*₽/);
|
|
});
|
|
|
|
it('содержит табы Обзор / Списания', async () => {
|
|
const wrapper = factory();
|
|
await flushPromises();
|
|
const text = wrapper.text();
|
|
expect(text).toContain('Обзор');
|
|
expect(text).toContain('Списания');
|
|
});
|
|
|
|
it('кнопка «Пополнить баланс» открывает TopupDialog', async () => {
|
|
const wrapper = factory();
|
|
await flushPromises();
|
|
const btn = wrapper.findAll('button').find((b) => b.text().includes('Пополнить баланс'));
|
|
expect(btn).toBeDefined();
|
|
await btn!.trigger('click');
|
|
expect((wrapper.vm as unknown as { topupOpen: boolean }).topupOpen).toBe(true);
|
|
});
|
|
|
|
it('не показывает pending-баннер (E4 — mock убран)', async () => {
|
|
const wrapper = factory();
|
|
await flushPromises();
|
|
expect(wrapper.text()).not.toContain('в обработке');
|
|
});
|
|
|
|
it('рендерит TierPricesPanel между BalanceCard и TransactionsTable', async () => {
|
|
const wrapper = factory();
|
|
await flushPromises();
|
|
expect(wrapper.text()).toContain('Цены за лид');
|
|
});
|
|
|
|
// Инцидент 14.07.2026: клиент создал платёж на 10 000 ₽, не довёл оплату до конца,
|
|
// ЮKassa через час его отменила — а кабинет обещал «баланс обновится автоматически».
|
|
describe('незавершённая оплата', () => {
|
|
it('говорит правду про отменённый платёж и предлагает оплатить снова', async () => {
|
|
vi.mocked(billingApi.getLastPayment).mockResolvedValue({
|
|
id: 3,
|
|
status: 'failed',
|
|
amount_rub: '10000.00',
|
|
failure_reason: 'expired_on_confirmation',
|
|
created_at: '2026-07-14T05:56:19+00:00',
|
|
});
|
|
|
|
const wrapper = factory();
|
|
await flushPromises();
|
|
|
|
const text = wrapper.text();
|
|
expect(text).toContain('Оплата не завершена');
|
|
expect(text).not.toContain('Платёж обрабатывается');
|
|
|
|
const btn = wrapper.findAll('button').find((b) => b.text().includes('Оплатить снова'));
|
|
expect(btn).toBeDefined();
|
|
await btn!.trigger('click');
|
|
expect((wrapper.vm as unknown as { topupOpen: boolean }).topupOpen).toBe(true);
|
|
});
|
|
|
|
it('про платёж, который ещё ждёт оплаты, не обещает зачисление', async () => {
|
|
vi.mocked(billingApi.getLastPayment).mockResolvedValue({
|
|
id: 4,
|
|
status: 'pending',
|
|
amount_rub: '5000.00',
|
|
failure_reason: null,
|
|
created_at: '2026-07-14T05:56:19+00:00',
|
|
});
|
|
|
|
const wrapper = factory();
|
|
await flushPromises();
|
|
|
|
expect(wrapper.text()).toContain('Оплата не завершена');
|
|
expect(wrapper.text()).not.toContain('Баланс обновится автоматически');
|
|
});
|
|
|
|
it('когда незавершённых оплат нет — баннера нет', async () => {
|
|
vi.mocked(billingApi.getLastPayment).mockResolvedValue(null);
|
|
|
|
const wrapper = factory();
|
|
await flushPromises();
|
|
|
|
expect(wrapper.text()).not.toContain('Оплата не завершена');
|
|
});
|
|
});
|
|
});
|