Files
portal/app/resources/js/api/billing.ts
T
Дмитрий a56dcb06b2 feat(биллинг): оплата по счёту (Этап 1) — счёт, акт, отметка оплаты
Клиент сам выставляет PDF-счёт (TopupDialog вкладка «По счёту»), счета и
акты — в отдельной вкладке «Счета». Админ (/admin/invoices) отмечает оплату
одной кнопкой → атомарно зачисляет баланс (BillingTopupService), формирует
Акт (без НДС, saas_upd_documents ДОП) и шлёт клиенту письмо «Счёт оплачен»
с вложением PDF-акта. PDF открываются inline в браузере (ASCII-имя).

- Сервисы InvoiceNumberGenerator/InvoiceService/ActService/InvoicePaymentService/PdfRenderer
- Контроллеры InvoiceController (клиент) + AdminInvoiceController (список+mark-paid)
- Модели SaasInvoice/SaasInvoiceItem/SaasUpdDocument; шаблоны pdf/invoice|act
- Нумерация СЧ-ГГГГ-NNNNN (advisory-lock); просрочка invoices:expire (cron)
- Наименование услуги: «Оплата генерации рекламных лидов»
- Зависимость barryvdh/laravel-dompdf (default_font dejavu sans); схема БД не менялась
- Этап 2 (автомат через ВТБ API) — отдельно, спека/план в docs/superpowers

Тесты: счета 13, Billing 138, фронт зелёные; larastan baseline +6 (Pest false-pos).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:25:16 +03:00

159 lines
5.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;
}
/**
* Ответ 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;
}