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>
180 lines
6.9 KiB
TypeScript
180 lines
6.9 KiB
TypeScript
import { apiClient, ensureCsrfCookie } from './client';
|
|
|
|
/**
|
|
* API-модуль биллинга (Sprint 2 Plan C).
|
|
*
|
|
* Эндпоинты под [auth:sanctum, tenant]: GET wallet/transactions/invoices
|
|
* (E3), POST topup (E1 — добавляется в Task 5). GET'ы не требуют CSRF-cookie.
|
|
*/
|
|
|
|
/** Тариф в составе ответа GET /api/billing/wallet (Billing v2 Spec A). */
|
|
export interface WalletTariff {
|
|
code: string;
|
|
name: string;
|
|
features: string[];
|
|
}
|
|
|
|
/** Один уровень tier-сетки в tiers_preview. */
|
|
export interface WalletTierPreview {
|
|
tier_no: number;
|
|
leads_in_tier: number | null;
|
|
price_rub: string;
|
|
}
|
|
|
|
/** Ответ GET /api/billing/wallet — кошелёк тенанта (Billing v2 Spec A). */
|
|
export interface Wallet {
|
|
balance_rub: string;
|
|
affordable_leads: number;
|
|
current_tier: { no: number; price_rub: string; leads_left_in_tier: number } | null;
|
|
next_tier: { no: number; price_rub: string; leads_in_tier: number } | null;
|
|
delivered_in_month: number;
|
|
runway_days: number | null;
|
|
tiers_preview: WalletTierPreview[];
|
|
tariff: WalletTariff | null;
|
|
}
|
|
|
|
/** GET /api/billing/wallet — балансы + текущий тариф + runway. */
|
|
export async function getWallet(): Promise<Wallet> {
|
|
const { data } = await apiClient.get<Wallet>('/api/billing/wallet');
|
|
return data;
|
|
}
|
|
|
|
/** Строка истории транзакций (GET /api/billing/transactions, Billing v2 Spec A). */
|
|
export interface BillingTransaction {
|
|
id: number;
|
|
code: string;
|
|
type:
|
|
| 'topup'
|
|
| 'lead_charge'
|
|
| 'migration'
|
|
| 'trial_bonus'
|
|
| 'manual_adjustment'
|
|
| 'historical_import'
|
|
| 'chargeback_writedown'
|
|
| 'chargeback_repayment';
|
|
description: string | null;
|
|
amount_rub: string;
|
|
amount_leads: number | null;
|
|
balance_rub_after: string;
|
|
display_amount_rub: string;
|
|
created_at: string;
|
|
}
|
|
|
|
/** Пагинированный ответ GET /api/billing/transactions. */
|
|
export interface TransactionsPage {
|
|
data: BillingTransaction[];
|
|
meta: { current_page: number; last_page: number; total: number; per_page: number };
|
|
}
|
|
|
|
/** Счёт тенанта (GET /api/billing/invoices). */
|
|
export interface BillingInvoice {
|
|
id: number;
|
|
invoice_number: string;
|
|
amount_total: string;
|
|
status: string;
|
|
issued_at: string;
|
|
expires_at: string | null;
|
|
has_pdf: boolean;
|
|
has_act: boolean;
|
|
pdf_url: string | null;
|
|
act_url: string | null;
|
|
}
|
|
|
|
/** Ответ POST /api/billing/invoices — созданный счёт. */
|
|
export interface CreatedInvoice {
|
|
id: number;
|
|
invoice_number: string;
|
|
amount_total: string;
|
|
pdf_url: string;
|
|
}
|
|
|
|
/** GET /api/billing/transactions — пагинированная история транзакций. */
|
|
export async function getTransactions(params: { page?: number; type?: string }): Promise<TransactionsPage> {
|
|
const { data } = await apiClient.get<TransactionsPage>('/api/billing/transactions', { params });
|
|
return data;
|
|
}
|
|
|
|
/** GET /api/billing/invoices — счета тенанта. */
|
|
export async function getInvoices(): Promise<{ data: BillingInvoice[] }> {
|
|
const { data } = await apiClient.get<{ data: BillingInvoice[] }>('/api/billing/invoices');
|
|
return data;
|
|
}
|
|
|
|
/** POST /api/billing/invoices — выставить счёт по реквизитам тенанта (оплата по счёту). */
|
|
export async function createInvoice(amountRub: number): Promise<CreatedInvoice> {
|
|
await ensureCsrfCookie();
|
|
const { data } = await apiClient.post<{ invoice: CreatedInvoice }>('/api/billing/invoices', {
|
|
amount_rub: amountRub,
|
|
});
|
|
return data.invoice;
|
|
}
|
|
|
|
/**
|
|
* Результат POST /api/billing/topup — две формы:
|
|
* • заглушка (флаг ВЫКЛ): transaction + balance_rub (мгновенное зачисление);
|
|
* • реальный шлюз (флаг ВКЛ): confirmation_url (редирект на оплату, баланс позже по webhook).
|
|
*/
|
|
export interface TopupResult {
|
|
transaction?: {
|
|
id: number;
|
|
type: string;
|
|
amount_rub: string;
|
|
balance_rub_after: string | null;
|
|
created_at: string;
|
|
};
|
|
balance_rub?: string;
|
|
confirmation_url?: string;
|
|
}
|
|
|
|
/** POST /api/billing/topup — пополнить рублёвый баланс (заглушка ИЛИ редирект на шлюз). */
|
|
export async function topup(amountRub: number): Promise<TopupResult> {
|
|
await ensureCsrfCookie();
|
|
const { data } = await apiClient.post<TopupResult>('/api/billing/topup', { amount_rub: amountRub });
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Незавершённая онлайн-оплата за последние сутки (pending/failed), либо null.
|
|
*
|
|
* Инцидент 14.07.2026: клиент ушёл со страницы оплаты, ЮKassa через час отменила платёж
|
|
* на 10 000 ₽ — а кабинет всё это время обещал «баланс обновится автоматически».
|
|
* Теперь показываем правду и даём оплатить снова.
|
|
*/
|
|
export interface LastPayment {
|
|
id: number;
|
|
status: 'pending' | 'failed';
|
|
amount_rub: string;
|
|
failure_reason: string | null;
|
|
created_at: string | null;
|
|
}
|
|
|
|
/** GET /api/billing/last-payment — судьба последней онлайн-оплаты (null = всё в порядке). */
|
|
export async function getLastPayment(): Promise<LastPayment | null> {
|
|
const { data } = await apiClient.get<{ data: LastPayment | null }>('/api/billing/last-payment');
|
|
return data.data;
|
|
}
|
|
|
|
/**
|
|
* Ответ GET /api/billing/balance-status — лёгкий статус баланса для UI префлайта
|
|
* (Billing v2 Spec C §3.6): питает баннер заморозки + индикатор ёмкости.
|
|
*/
|
|
export interface BalanceStatus {
|
|
/** ISO-дата заморозки или null (не заморожен). */
|
|
frozen_by_balance_at: string | null;
|
|
balance_rub: string;
|
|
/** Сколько лидов покрывает баланс по текущему тарифу. */
|
|
capacity_leads: number;
|
|
/** Суммарный дневной заказ активных не-заблокированных проектов. */
|
|
required_leads_per_day: number;
|
|
/** На сколько лидов заказ превышает ёмкость (0 если хватает). */
|
|
deficit_leads: number;
|
|
/** Сколько ₽ не хватает, чтобы покрыть дефицит (scale 2, "0.00" если хватает). */
|
|
deficit_rub: string;
|
|
}
|
|
|
|
/** GET /api/billing/balance-status — статус для баннера заморозки и индикатора ёмкости. */
|
|
export async function getBalanceStatus(): Promise<BalanceStatus> {
|
|
const { data } = await apiClient.get<BalanceStatus>('/api/billing/balance-status');
|
|
return data;
|
|
}
|