Compare commits

...

5 Commits

Author SHA1 Message Date
Дмитрий 253d1b7f39 feat(ui): тип лица полными словами, зелёные дни недели, скрытие банк-реквизитов у физлица
Accessibility (Pa11y live) / a11y (push) Waiting to run
SAST — Semgrep / Semgrep SAST scan (push) Waiting to run
- Настройки→Реквизиты и диалог проекта: «Физлицо»→«Физическое лицо», «Юрлицо»→«Юридическое лицо» (ключи value не тронуты)
- У физлица скрыт блок «Реквизиты для оплаты» (банковских реквизитов нет; оплата по счёту — только для юр/ИП)
- Диалог проекта: выбранные дни недели залиты зелёным #0f6e56 как в ProjectDetailsDrawer

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 13:36:58 +03:00
Дмитрий 093cc8729b docs(ПИЛОТ): снимок 29.06 — фича «оплата по счёту» (Этап 1) выкачена на прод + реквизиты ИП в legal_entities
Accessibility (Pa11y live) / a11y (push) Waiting to run
2026-06-29 11:47:34 +03:00
Дмитрий cdfae077a3 feat(биллинг): оплата по счёту (Этап 1) — счёт, акт, отметка оплаты
Accessibility (Pa11y live) / a11y (push) Waiting to run
SAST — Semgrep / Semgrep SAST scan (push) Waiting to run
Клиент сам выставляет 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:32:21 +03:00
Дмитрий 2281907b8a docs(ПИЛОТ): снимок 28.06 — сквозная сверка прод↔git↔бэкап (1:1) + Тенанты + фронт-стенд зелёный
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Прод приведён к gitea-main 84dfbc85 один-в-один (rsync-checksum, потерь нет);
бэкап цел; экран Тенанты на серверную пагинацию выкачен; 992 фронт-теста зелёные;
деньги t2=1 839 405₽ целы. Остатки не-git на проде объяснены (.bak-precutover до 03.07).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 07:10:10 +03:00
Дмитрий 84dfbc857a test(фронт): привёл стенд в зелёный — 10 протухших спеков под актуальные компоненты
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Все падения — устаревшие ожидания тестов (компоненты менялись намеренно):
SettingsView (роутер+вкладка Реквизиты+события), LegalDoc (реальные доки под ЮKassa),
ProjectsView (BulkActionsBar v-show→isVisible), ErrorView (убран фейк REQ/INC),
PricingTiers (формат «500 ₽»), KanbanCard (costKopecks→«—»), ChangePassword (дата из API),
DealDetail (русские ярлыки статусов), DealsView (RuDateField на v-menu), SupplierIntegration
(window.confirm→v-dialog). Изменены ТОЛЬКО тесты, компоненты не тронуты.
Полный прогон: 127 файлов / 992 теста зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:59:01 +03:00
56 changed files with 4264 additions and 267 deletions
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\SaasInvoice;
use Illuminate\Console\Command;
/**
* Помечает просроченные неоплаченные счета статусом overdue (Этап 1 «оплата по счёту»).
* Только issued overdue по expires_at; оплаченные/отменённые не трогаются.
*/
class ExpireInvoicesCommand extends Command
{
protected $signature = 'invoices:expire';
protected $description = 'Помечает просроченные неоплаченные счета статусом overdue';
public function handle(): int
{
SaasInvoice::where('status', SaasInvoice::STATUS_ISSUED)
->where('expires_at', '<', now())
->update(['status' => SaasInvoice::STATUS_OVERDUE]);
return self::SUCCESS;
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Billing\Invoice\InvoicePaymentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* SaaS-admin: список счетов + ручная отметка оплаты (Этап 1 «оплата по счёту»).
* Зона saas-admin/admin-db. Зачисление делегируется InvoicePaymentService
* (идемпотентно, под tenant RLS-контекстом).
*/
class AdminInvoiceController extends Controller
{
public function __construct(private readonly InvoicePaymentService $payments) {}
public function index(Request $request): JsonResponse
{
$perPage = min(100, max(10, (int) $request->query('per_page', 25)));
$query = DB::table('saas_invoices as i')
->leftJoin('tenants as t', 't.id', '=', 'i.tenant_id')
->select(
'i.id', 'i.invoice_number', 'i.amount_total', 'i.status',
'i.issued_at', 'i.expires_at', 'i.tenant_id', 't.organization_name as tenant_name', 'i.payer_name'
);
$status = $request->query('status');
if (is_string($status) && in_array($status, ['issued', 'paid', 'overdue', 'cancelled'], true)) {
$query->where('i.status', $status);
}
$search = trim((string) $request->query('search', ''));
if ($search !== '') {
$query->where(function ($q) use ($search) {
$q->where('i.invoice_number', 'ilike', "%{$search}%")
->orWhere('i.payer_name', 'ilike', "%{$search}%")
->orWhere('t.organization_name', 'ilike', "%{$search}%");
});
}
$page = $query->orderByDesc('i.issued_at')->paginate($perPage);
return response()->json([
'data' => array_map(static fn ($r) => (array) $r, $page->items()),
'meta' => [
'current_page' => $page->currentPage(),
'last_page' => $page->lastPage(),
'total' => $page->total(),
'per_page' => $page->perPage(),
],
]);
}
public function markPaid(Request $request, int $id): JsonResponse
{
$this->payments->markPaid($id);
return response()->json(['status' => 'ok']);
}
}
@@ -307,7 +307,14 @@ class BillingController extends Controller
$rows = DB::table('saas_invoices')
->where('tenant_id', $tenantId)
->orderBy('issued_at', 'desc')
->get(['id', 'invoice_number', 'amount_total', 'status', 'issued_at', 'pdf_path']);
->get(['id', 'invoice_number', 'amount_total', 'status', 'issued_at', 'expires_at', 'pdf_path']);
// Какие счета уже имеют закрывающий документ (акт) — для кнопки «Скачать акт».
$actInvoiceIds = DB::table('saas_upd_documents')
->where('tenant_id', $tenantId)
->whereNotNull('invoice_id')
->pluck('invoice_id')
->flip();
return response()->json([
'data' => $rows->map(static fn (\stdClass $r): array => [
@@ -316,7 +323,11 @@ class BillingController extends Controller
'amount_total' => $r->amount_total,
'status' => $r->status,
'issued_at' => $r->issued_at,
'expires_at' => $r->expires_at,
'has_pdf' => $r->pdf_path !== null,
'has_act' => isset($actInvoiceIds[$r->id]),
'pdf_url' => $r->pdf_path !== null ? "/api/billing/invoices/{$r->id}/pdf" : null,
'act_url' => isset($actInvoiceIds[$r->id]) ? "/api/billing/invoices/{$r->id}/act" : null,
])->all(),
]);
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\SaasInvoice;
use App\Models\SaasUpdDocument;
use App\Models\User;
use App\Services\Billing\Invoice\InvoiceService;
use App\Services\Billing\Invoice\RequisitesIncompleteException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Клиентские эндпоинты «оплата по счёту» (под middleware auth:sanctum + tenant).
* Создание счёта (самообслуживание), скачивание PDF счёта и акта (tenant-scoped).
*/
class InvoiceController extends Controller
{
public function __construct(private readonly InvoiceService $invoices) {}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'amount_rub' => ['required', 'numeric', 'min:100', 'max:1000000', 'decimal:0,2'],
]);
/** @var User $user */
$user = $request->user();
$amountRub = bcadd((string) $validated['amount_rub'], '0', 2);
try {
$invoice = $this->invoices->create((int) $user->tenant_id, $amountRub, (int) $user->id);
} catch (RequisitesIncompleteException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json(['invoice' => [
'id' => $invoice->id,
'invoice_number' => $invoice->invoice_number,
'amount_total' => $invoice->amount_total,
'pdf_url' => "/api/billing/invoices/{$invoice->id}/pdf",
]], 201);
}
public function pdf(Request $request, int $id): Response
{
/** @var User $user */
$user = $request->user();
$invoice = SaasInvoice::where('id', $id)->where('tenant_id', $user->tenant_id)->firstOrFail();
abort_if($invoice->pdf_path === null || ! Storage::disk('local')->exists($invoice->pdf_path), 404);
return $this->inlinePdf($invoice->pdf_path, 'Schet-'.$invoice->invoice_number);
}
public function act(Request $request, int $id): Response
{
/** @var User $user */
$user = $request->user();
$invoice = SaasInvoice::where('id', $id)->where('tenant_id', $user->tenant_id)->firstOrFail();
$act = SaasUpdDocument::where('invoice_id', $invoice->id)->firstOrFail();
abort_if($act->pdf_path === null || ! Storage::disk('local')->exists($act->pdf_path), 404);
return $this->inlinePdf($act->pdf_path, 'Akt-'.$act->upd_number);
}
/**
* Отдать PDF для просмотра в браузере (inline) с ASCII-безопасным именем
* кириллица в Content-Disposition ломала имя файла в браузере (random GUID).
*/
private function inlinePdf(string $path, string $baseName): Response
{
$content = Storage::disk('local')->get($path);
$filename = Str::ascii($baseName).'.pdf'; // напр. Schet-SCh-2026-00001.pdf
return response($content, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$filename.'"',
]);
}
}
+26
View File
@@ -8,9 +8,11 @@ use App\Models\Tenant;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
/**
* Email-уведомление об оплате тарифного счёта (ТЗ §18.5, событие
@@ -31,6 +33,10 @@ class InvoicePaidNotification extends Mailable
public string $amountRub,
public ?string $invoiceNumber,
public ?string $tariffName,
/** Относительный путь PDF-акта на диске 'local' (для вложения). */
public ?string $actPdfPath = null,
/** Номер акта — для имени файла вложения. */
public ?string $actNumber = null,
) {}
public function envelope(): Envelope
@@ -53,4 +59,24 @@ class InvoicePaidNotification extends Mailable
],
);
}
/**
* Вложение: PDF закрывающего документа (Акт), если он сформирован.
*
* @return array<int, Attachment>
*/
public function attachments(): array
{
if ($this->actPdfPath === null) {
return [];
}
$name = 'Akt-'.Str::ascii((string) $this->actNumber).'.pdf';
return [
Attachment::fromStorageDisk('local', $this->actPdfPath)
->as($name)
->withMime('application/pdf'),
];
}
}
+1 -1
View File
@@ -19,6 +19,6 @@ class LegalEntity extends Model
'code', 'name', 'short_name', 'legal_form', 'inn', 'kpp', 'ogrn',
'okpo', 'legal_address', 'actual_address', 'bank_name', 'bank_account',
'bank_bik', 'bank_corr', 'director_name', 'director_post',
'director_basis', 'vat_mode',
'director_basis', 'vat_mode', 'is_default',
];
}
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
/**
* Счёт на оплату (schema.sql table saas_invoices). RLS по tenant_id.
* Этап 1 «оплата по счёту»: выставляется клиентом, оплачивается банковским
* переводом, отмечается администратором (InvoicePaymentService).
*
* @property int $id
* @property int $tenant_id
* @property int $legal_entity_id
* @property string $invoice_number
* @property string $payer_type
* @property string|null $payer_name
* @property string|null $payer_inn
* @property string|null $payer_kpp
* @property string|null $payer_address
* @property string|null $payer_email
* @property string $amount_net
* @property string|null $vat_rate
* @property string|null $vat_amount
* @property string $amount_total
* @property string|null $payment_purpose
* @property int|null $transaction_id
* @property string|null $pdf_path
* @property string $status
* @property Carbon|null $issued_at
* @property Carbon|null $expires_at
* @property Carbon|null $paid_at
* @property Carbon|null $cancelled_at
*/
class SaasInvoice extends Model
{
public $timestamps = false;
public const STATUS_DRAFT = 'draft';
public const STATUS_ISSUED = 'issued';
public const STATUS_PAID = 'paid';
public const STATUS_OVERDUE = 'overdue';
public const STATUS_CANCELLED = 'cancelled';
protected $fillable = [
'tenant_id', 'legal_entity_id', 'invoice_number',
'payer_type', 'payer_name', 'payer_inn', 'payer_kpp', 'payer_address', 'payer_email',
'amount_net', 'vat_rate', 'vat_amount', 'amount_total', 'payment_purpose',
'transaction_id', 'pdf_path', 'status',
'issued_at', 'expires_at', 'paid_at', 'cancelled_at',
];
protected function casts(): array
{
return [
'amount_net' => 'decimal:2',
'vat_amount' => 'decimal:2',
'amount_total' => 'decimal:2',
'issued_at' => 'datetime',
'expires_at' => 'datetime',
'paid_at' => 'datetime',
'cancelled_at' => 'datetime',
];
}
/** @return HasMany<SaasInvoiceItem, $this> */
public function items(): HasMany
{
return $this->hasMany(SaasInvoiceItem::class, 'invoice_id');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Позиция счёта (schema.sql table saas_invoice_items). RLS косвенно через invoice_id.
*
* @property int $id
* @property int $invoice_id
* @property string $name
* @property string|null $okpd2
* @property string $quantity
* @property string $unit
* @property string $price
* @property string $amount_net
* @property string|null $vat_rate
* @property string|null $vat_amount
* @property string $amount_total
*/
class SaasInvoiceItem extends Model
{
public $timestamps = false;
protected $fillable = [
'invoice_id', 'name', 'okpd2', 'quantity', 'unit',
'price', 'amount_net', 'vat_rate', 'vat_amount', 'amount_total',
];
protected function casts(): array
{
return [
'quantity' => 'decimal:3',
'price' => 'decimal:2',
'amount_net' => 'decimal:2',
'amount_total' => 'decimal:2',
];
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* Закрывающий документ (schema.sql table saas_upd_documents). RLS по tenant_id.
* Для УСН без НДС используем upd_function='ДОП' (передаточный документ без
* счёта-фактуры) формируется как Акт об оказании услуг (ActService).
*
* @property int $id
* @property int $tenant_id
* @property int $legal_entity_id
* @property string $upd_number
* @property string $upd_function
* @property int|null $correction_for
* @property string $buyer_type
* @property string|null $buyer_name
* @property string|null $buyer_inn
* @property string|null $buyer_kpp
* @property string|null $buyer_address
* @property string $amount_net
* @property string|null $vat_rate
* @property string|null $vat_amount
* @property string $amount_total
* @property int|null $invoice_id
* @property int|null $transaction_id
* @property string|null $pdf_path
* @property string $status
* @property Carbon|null $issued_at
*/
class SaasUpdDocument extends Model
{
public $timestamps = false;
protected $table = 'saas_upd_documents';
public const FUNCTION_DOP = 'ДОП';
protected $fillable = [
'tenant_id', 'legal_entity_id', 'upd_number', 'upd_function', 'correction_for',
'buyer_type', 'buyer_name', 'buyer_inn', 'buyer_kpp', 'buyer_address',
'amount_net', 'vat_rate', 'vat_amount', 'amount_total',
'invoice_id', 'transaction_id', 'pdf_path', 'status', 'issued_at',
];
protected function casts(): array
{
return [
'amount_net' => 'decimal:2',
'vat_amount' => 'decimal:2',
'amount_total' => 'decimal:2',
'issued_at' => 'datetime',
];
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing\Invoice;
use App\Models\LegalEntity;
use App\Models\SaasInvoice;
use App\Models\SaasUpdDocument;
use Illuminate\Support\Carbon;
/**
* Формирует закрывающий документ (Акт об оказании услуг, без НДС, УСН) по
* оплаченному счёту. Хранится в saas_upd_documents (upd_function=ДОП передаточный
* документ без счёта-фактуры). PDF в приватный storage.
*/
final class ActService
{
public function __construct(private readonly PdfRenderer $pdf) {}
public function createForInvoice(SaasInvoice $invoice, int $transactionId): SaasUpdDocument
{
$seller = LegalEntity::findOrFail($invoice->legal_entity_id);
$now = Carbon::now('Europe/Moscow');
$number = str_replace('СЧ-', 'АКТ-', (string) $invoice->invoice_number);
$act = SaasUpdDocument::create([
'tenant_id' => $invoice->tenant_id,
'legal_entity_id' => $invoice->legal_entity_id,
'upd_number' => $number,
'upd_function' => SaasUpdDocument::FUNCTION_DOP,
'buyer_type' => $invoice->payer_type,
'buyer_name' => $invoice->payer_name,
'buyer_inn' => $invoice->payer_inn,
'buyer_kpp' => $invoice->payer_kpp,
'buyer_address' => $invoice->payer_address,
'amount_net' => $invoice->amount_total,
'vat_rate' => 0,
'vat_amount' => 0,
'amount_total' => $invoice->amount_total,
'invoice_id' => $invoice->id,
'transaction_id' => $transactionId,
'status' => 'issued',
'issued_at' => $now,
]);
$path = $this->pdf->renderToStorage('pdf.act', [
'act' => $act,
'seller' => $seller,
'invoiceNumber' => $invoice->invoice_number,
], "acts/{$act->id}-{$number}.pdf");
$act->pdf_path = $path;
$act->save();
return $act;
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing\Invoice;
use App\Models\SaasInvoice;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Атомарная нумерация счетов: СЧ-ГГГГ-NNNNN, последовательно по legal_entity_id+год.
* Advisory-lock на пару (legal_entity_id, year) сериализует параллельные вызовы;
* UNIQUE (legal_entity_id, invoice_number) в схеме последний барьер от дублей.
* Вызывать ВНУТРИ транзакции (xact-lock держится до COMMIT).
*/
final class InvoiceNumberGenerator
{
public function next(int $legalEntityId, ?Carbon $now = null): string
{
$now ??= Carbon::now('Europe/Moscow');
$year = (int) $now->year;
// Advisory lock на пару чисел (legal_entity_id, year) — освобождается на COMMIT.
DB::statement('SELECT pg_advisory_xact_lock(?, ?)', [$legalEntityId, $year]);
$prefix = sprintf('СЧ-%d-', $year);
$maxNumber = SaasInvoice::query()
->where('legal_entity_id', $legalEntityId)
->where('invoice_number', 'like', $prefix.'%')
->orderByDesc('invoice_number')
->value('invoice_number');
$seq = 1;
if ($maxNumber !== null) {
$seq = ((int) substr((string) $maxNumber, strlen($prefix))) + 1;
}
return sprintf('%s%05d', $prefix, $seq);
}
}
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing\Invoice;
use App\Mail\InvoicePaidNotification;
use App\Models\SaasInvoice;
use App\Models\SaasTransaction;
use App\Models\SaasUpdDocument;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Billing\BillingTopupService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/**
* Отметка счёта оплаченным: атомарный claim issued→paid (идемпотентно),
* зачисление баланса (BillingTopupService), создание акта, письмо клиенту.
* Зеркалит идемпотентность и RLS-контекст PaymentWebhookController.
*/
final class InvoicePaymentService
{
public function __construct(
private readonly BillingTopupService $topup,
private readonly ActService $acts,
) {}
public function markPaid(int $invoiceId): void
{
$invoice = SaasInvoice::findOrFail($invoiceId);
$credited = DB::transaction(function () use ($invoice): bool {
// RLS-контекст транзакции (PgBouncer-safe SET LOCAL), как в webhook.
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $invoice->tenant_id);
// Атомарно занимаем issued→paid; 0 строк = уже оплачен (дубль/гонка).
$claimed = SaasInvoice::where('id', $invoice->id)
->where('status', SaasInvoice::STATUS_ISSUED)
->update(['status' => SaasInvoice::STATUS_PAID, 'paid_at' => now()]);
if ($claimed === 0) {
return false; // идемпотентный no-op
}
$tx = SaasTransaction::create([
'tenant_id' => $invoice->tenant_id,
'type' => 'topup',
'amount_rub' => $invoice->amount_total,
'gateway_code' => 'bank_transfer',
'payment_method' => 'bank_transfer',
'legal_entity_id' => $invoice->legal_entity_id,
'invoice_id' => $invoice->id,
'status' => 'success',
'description' => 'Оплата по счёту '.$invoice->invoice_number,
'created_at' => now(),
'completed_at' => now(),
]);
$balanceTx = $this->topup->topup((int) $invoice->tenant_id, (string) $invoice->amount_total, null);
$act = $this->acts->createForInvoice($invoice->fresh(), (int) $tx->id);
SaasTransaction::where('id', $tx->id)->update([
'balance_rub_after' => $balanceTx->balance_rub_after,
'balance_transaction_id' => $balanceTx->id,
'upd_id' => $act->id,
]);
SaasInvoice::where('id', $invoice->id)->update(['transaction_id' => $tx->id]);
return true;
});
if (! $credited) {
return;
}
// Письмо — после COMMIT (избегаем отправки при откате транзакции).
// К письму прикладываем PDF-акт (закрывающий документ).
$tenant = Tenant::find($invoice->tenant_id);
$recipient = User::where('tenant_id', $invoice->tenant_id)->orderBy('id')->first();
if ($tenant !== null && $recipient !== null) {
$act = SaasUpdDocument::where('invoice_id', $invoice->id)->first();
Mail::to($recipient->email)->queue(new InvoicePaidNotification(
$recipient,
$tenant,
(string) $invoice->amount_total,
$invoice->invoice_number,
null,
$act?->pdf_path,
$act?->upd_number,
));
}
}
}
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing\Invoice;
use App\Models\LegalEntity;
use App\Models\SaasInvoice;
use App\Models\SaasInvoiceItem;
use App\Models\TenantRequisites;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Создание счёта на пополнение баланса (УСН, без НДС). Вызывается из HTTP под
* middleware tenant (RLS-контекст). Нумерация атомарна. PDF в приватный storage.
*/
final class InvoiceService
{
/** Наименование услуги в счёте/акте (УСН без НДС). */
public const SERVICE_NAME = 'Оплата генерации рекламных лидов';
public function __construct(
private readonly InvoiceNumberGenerator $numbers,
private readonly PdfRenderer $pdf,
) {}
public function create(int $tenantId, string $amountRub, ?int $userId): SaasInvoice
{
$req = TenantRequisites::where('tenant_id', $tenantId)->first();
if ($req === null || blank($req->inn)) {
throw new RequisitesIncompleteException('Заполните реквизиты компании, чтобы выставить счёт.');
}
// «Наш» получатель — юрлицо-оператор по флагу is_default; иначе первое.
$seller = LegalEntity::where('is_default', true)->first()
?? LegalEntity::orderBy('id')->firstOrFail();
$payerEmail = null;
if ($userId !== null) {
$email = User::query()->whereKey($userId)->value('email');
$payerEmail = is_string($email) && $email !== '' ? $email : null;
}
return DB::transaction(function () use ($tenantId, $amountRub, $req, $seller, $payerEmail) {
$now = Carbon::now('Europe/Moscow');
$number = $this->numbers->next((int) $seller->id, $now);
$invoice = SaasInvoice::create([
'tenant_id' => $tenantId,
'legal_entity_id' => $seller->id,
'invoice_number' => $number,
'payer_type' => $req->subject_type === 'individual' ? 'individual' : 'legal',
'payer_name' => $req->legal_name ?? $req->contact_name,
'payer_inn' => $req->inn,
'payer_kpp' => $req->kpp,
'payer_address' => $req->legal_address,
'payer_email' => $payerEmail,
'amount_net' => $amountRub,
'vat_rate' => 0,
'vat_amount' => 0,
'amount_total' => $amountRub,
'payment_purpose' => 'Оплата по счёту '.$number.'. '.self::SERVICE_NAME.'. Без НДС.',
'status' => SaasInvoice::STATUS_ISSUED,
'issued_at' => $now,
'expires_at' => $now->copy()->addWeekdays(5),
]);
SaasInvoiceItem::create([
'invoice_id' => $invoice->id,
'name' => self::SERVICE_NAME,
'quantity' => 1,
'unit' => 'усл.',
'price' => $amountRub,
'amount_net' => $amountRub,
'vat_rate' => 0,
'vat_amount' => 0,
'amount_total' => $amountRub,
]);
$path = $this->pdf->renderToStorage('pdf.invoice', [
'invoice' => $invoice,
'items' => $invoice->items()->get(),
'seller' => $seller,
], "invoices/{$invoice->id}-{$number}.pdf");
$invoice->pdf_path = $path;
$invoice->save();
return $invoice;
});
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing\Invoice;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Support\Facades\Storage;
/**
* Рендер Blade-шаблона в PDF и сохранение в приватный storage (disk 'local').
* Возвращает относительный путь для saas_invoices.pdf_path / saas_upd_documents.pdf_path.
*/
final class PdfRenderer
{
/**
* @param array<string,mixed> $data
*/
public function renderToStorage(string $view, array $data, string $relativePath): string
{
$pdf = Pdf::loadView($view, $data)->setPaper('a4');
Storage::disk('local')->put($relativePath, $pdf->output());
return $relativePath;
}
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing\Invoice;
use RuntimeException;
/**
* Реквизиты компании клиента не заполнены счёт выставить нельзя.
*/
final class RequisitesIncompleteException extends RuntimeException {}
+1
View File
@@ -7,6 +7,7 @@
"license": "MIT",
"require": {
"php": "^8.3",
"barryvdh/laravel-dompdf": "^3.1",
"laravel/framework": "^13.7",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^3.0",
+523 -144
View File
@@ -4,8 +4,85 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "10306f01cb35d564d5004d2202f0c7b3",
"content-hash": "da84c833d162bd54a2eff0f338eead8a",
"packages": [
{
"name": "barryvdh/laravel-dompdf",
"version": "v3.1.2",
"source": {
"type": "git",
"url": "https://github.com/barryvdh/laravel-dompdf.git",
"reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/ee3b72b19ccdf57d0243116ecb2b90261344dedc",
"reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc",
"shasum": ""
},
"require": {
"dompdf/dompdf": "^3.0",
"illuminate/support": "^9|^10|^11|^12|^13.0",
"php": "^8.1"
},
"require-dev": {
"larastan/larastan": "^2.7|^3.0",
"orchestra/testbench": "^7|^8|^9.16|^10|^11.0",
"phpro/grumphp": "^2.5",
"squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"PDF": "Barryvdh\\DomPDF\\Facade\\Pdf",
"Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf"
},
"providers": [
"Barryvdh\\DomPDF\\ServiceProvider"
]
},
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"psr-4": {
"Barryvdh\\DomPDF\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Barry vd. Heuvel",
"email": "barryvdh@gmail.com"
}
],
"description": "A DOMPDF Wrapper for Laravel",
"keywords": [
"dompdf",
"laravel",
"pdf"
],
"support": {
"issues": "https://github.com/barryvdh/laravel-dompdf/issues",
"source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.2"
},
"funding": [
{
"url": "https://fruitcake.nl",
"type": "custom"
},
{
"url": "https://github.com/barryvdh",
"type": "github"
}
],
"time": "2026-02-21T08:51:10+00:00"
},
{
"name": "brick/math",
"version": "0.14.8",
@@ -456,6 +533,161 @@
],
"time": "2024-02-05T11:56:58+00:00"
},
{
"name": "dompdf/dompdf",
"version": "v3.1.5",
"source": {
"type": "git",
"url": "https://github.com/dompdf/dompdf.git",
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
"shasum": ""
},
"require": {
"dompdf/php-font-lib": "^1.0.0",
"dompdf/php-svg-lib": "^1.0.0",
"ext-dom": "*",
"ext-mbstring": "*",
"masterminds/html5": "^2.0",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"ext-gd": "*",
"ext-json": "*",
"ext-zip": "*",
"mockery/mockery": "^1.3",
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "^3.5",
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
},
"suggest": {
"ext-gd": "Needed to process images",
"ext-gmagick": "Improves image processing performance",
"ext-imagick": "Improves image processing performance",
"ext-zlib": "Needed for pdf stream compression"
},
"type": "library",
"autoload": {
"psr-4": {
"Dompdf\\": "src/"
},
"classmap": [
"lib/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1"
],
"authors": [
{
"name": "The Dompdf Community",
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
}
],
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
"homepage": "https://github.com/dompdf/dompdf",
"support": {
"issues": "https://github.com/dompdf/dompdf/issues",
"source": "https://github.com/dompdf/dompdf/tree/v3.1.5"
},
"time": "2026-03-03T13:54:37+00:00"
},
{
"name": "dompdf/php-font-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-font-lib.git",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
},
"type": "library",
"autoload": {
"psr-4": {
"FontLib\\": "src/FontLib"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "The FontLib Community",
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse, export and make subsets of different types of font files.",
"homepage": "https://github.com/dompdf/php-font-lib",
"support": {
"issues": "https://github.com/dompdf/php-font-lib/issues",
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
},
"time": "2026-01-20T14:10:26+00:00"
},
{
"name": "dompdf/php-svg-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-svg-lib.git",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabberworm/php-css-parser": "^8.4 || ^9.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
},
"type": "library",
"autoload": {
"psr-4": {
"Svg\\": "src/Svg"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "The SvgLib Community",
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse and export to PDF SVG files.",
"homepage": "https://github.com/dompdf/php-svg-lib",
"support": {
"issues": "https://github.com/dompdf/php-svg-lib/issues",
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
},
"time": "2026-01-02T16:01:13+00:00"
},
{
"name": "dragonmantank/cron-expression",
"version": "v3.6.0",
@@ -2357,6 +2589,73 @@
},
"time": "2022-12-02T22:17:43+00:00"
},
{
"name": "masterminds/html5",
"version": "2.10.1",
"source": {
"type": "git",
"url": "https://github.com/Masterminds/html5-php.git",
"reference": "fd5018f6815fff903946d0564977b44ce8010e29"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29",
"reference": "fd5018f6815fff903946d0564977b44ce8010e29",
"shasum": ""
},
"require": {
"ext-dom": "*",
"php": ">=5.3.0"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.7-dev"
}
},
"autoload": {
"psr-4": {
"Masterminds\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Matt Butcher",
"email": "technosophos@gmail.com"
},
{
"name": "Matt Farina",
"email": "matt@mattfarina.com"
},
{
"name": "Asmir Mustafic",
"email": "goetas@gmail.com"
}
],
"description": "An HTML5 parser and serializer.",
"homepage": "http://masterminds.github.io/html5-php",
"keywords": [
"HTML5",
"dom",
"html",
"parser",
"querypath",
"serializer",
"xml"
],
"support": {
"issues": "https://github.com/Masterminds/html5-php/issues",
"source": "https://github.com/Masterminds/html5-php/tree/2.10.1"
},
"time": "2026-06-23T18:43:15+00:00"
},
{
"name": "monolog/monolog",
"version": "3.10.0",
@@ -4017,6 +4316,86 @@
},
"time": "2025-12-14T04:43:48+00:00"
},
{
"name": "sabberworm/php-css-parser",
"version": "v9.4.0",
"source": {
"type": "git",
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "1.4.0",
"phpstan/extension-installer": "1.4.3",
"phpstan/phpstan": "1.12.33 || 2.2.2",
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.16",
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11",
"phpunit/phpunit": "8.5.52",
"rawr/phpunit-data-provider": "3.3.1",
"rector/rector": "1.2.10 || 2.4.6",
"rector/type-perfect": "1.0.0 || 2.1.3",
"squizlabs/php_codesniffer": "4.0.1",
"thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3"
},
"suggest": {
"ext-mbstring": "for parsing UTF-8 CSS"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "9.5.x-dev"
}
},
"autoload": {
"files": [
"src/Rule/Rule.php",
"src/RuleSet/RuleContainer.php"
],
"psr-4": {
"Sabberworm\\CSS\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Raphael Schweikert"
},
{
"name": "Oliver Klee",
"email": "github@oliverklee.de"
},
{
"name": "Jake Hotson",
"email": "jake.github@qzdesign.co.uk"
}
],
"description": "Parser for CSS Files written in PHP",
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
"keywords": [
"css",
"parser",
"stylesheet"
],
"support": {
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0"
},
"time": "2026-06-18T15:10:53+00:00"
},
{
"name": "symfony/clock",
"version": "v7.4.8",
@@ -6606,6 +6985,149 @@
],
"time": "2026-03-30T13:44:50+00:00"
},
{
"name": "thecodingmachine/safe",
"version": "v3.4.0",
"source": {
"type": "git",
"url": "https://github.com/thecodingmachine/safe.git",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpstan/phpstan": "^2",
"phpunit/phpunit": "^10",
"squizlabs/php_codesniffer": "^3.2"
},
"type": "library",
"autoload": {
"files": [
"lib/special_cases.php",
"generated/apache.php",
"generated/apcu.php",
"generated/array.php",
"generated/bzip2.php",
"generated/calendar.php",
"generated/classobj.php",
"generated/com.php",
"generated/cubrid.php",
"generated/curl.php",
"generated/datetime.php",
"generated/dir.php",
"generated/eio.php",
"generated/errorfunc.php",
"generated/exec.php",
"generated/fileinfo.php",
"generated/filesystem.php",
"generated/filter.php",
"generated/fpm.php",
"generated/ftp.php",
"generated/funchand.php",
"generated/gettext.php",
"generated/gmp.php",
"generated/gnupg.php",
"generated/hash.php",
"generated/ibase.php",
"generated/ibmDb2.php",
"generated/iconv.php",
"generated/image.php",
"generated/imap.php",
"generated/info.php",
"generated/inotify.php",
"generated/json.php",
"generated/ldap.php",
"generated/libxml.php",
"generated/lzf.php",
"generated/mailparse.php",
"generated/mbstring.php",
"generated/misc.php",
"generated/mysql.php",
"generated/mysqli.php",
"generated/network.php",
"generated/oci8.php",
"generated/opcache.php",
"generated/openssl.php",
"generated/outcontrol.php",
"generated/pcntl.php",
"generated/pcre.php",
"generated/pgsql.php",
"generated/posix.php",
"generated/ps.php",
"generated/pspell.php",
"generated/readline.php",
"generated/rnp.php",
"generated/rpminfo.php",
"generated/rrd.php",
"generated/sem.php",
"generated/session.php",
"generated/shmop.php",
"generated/sockets.php",
"generated/sodium.php",
"generated/solr.php",
"generated/spl.php",
"generated/sqlsrv.php",
"generated/ssdeep.php",
"generated/ssh2.php",
"generated/stream.php",
"generated/strings.php",
"generated/swoole.php",
"generated/uodbc.php",
"generated/uopz.php",
"generated/url.php",
"generated/var.php",
"generated/xdiff.php",
"generated/xml.php",
"generated/xmlrpc.php",
"generated/yaml.php",
"generated/yaz.php",
"generated/zip.php",
"generated/zlib.php"
],
"classmap": [
"lib/DateTime.php",
"lib/DateTimeImmutable.php",
"lib/Exceptions/",
"generated/Exceptions/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
"support": {
"issues": "https://github.com/thecodingmachine/safe/issues",
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
},
"funding": [
{
"url": "https://github.com/OskarStark",
"type": "github"
},
{
"url": "https://github.com/shish",
"type": "github"
},
{
"url": "https://github.com/silasjoisten",
"type": "github"
},
{
"url": "https://github.com/staabm",
"type": "github"
}
],
"time": "2026-02-04T18:08:13+00:00"
},
{
"name": "tijsverkoyen/css-to-inline-styles",
"version": "v2.4.0",
@@ -15471,149 +15993,6 @@
},
"time": "2026-02-17T17:25:14+00:00"
},
{
"name": "thecodingmachine/safe",
"version": "v3.4.0",
"source": {
"type": "git",
"url": "https://github.com/thecodingmachine/safe.git",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpstan/phpstan": "^2",
"phpunit/phpunit": "^10",
"squizlabs/php_codesniffer": "^3.2"
},
"type": "library",
"autoload": {
"files": [
"lib/special_cases.php",
"generated/apache.php",
"generated/apcu.php",
"generated/array.php",
"generated/bzip2.php",
"generated/calendar.php",
"generated/classobj.php",
"generated/com.php",
"generated/cubrid.php",
"generated/curl.php",
"generated/datetime.php",
"generated/dir.php",
"generated/eio.php",
"generated/errorfunc.php",
"generated/exec.php",
"generated/fileinfo.php",
"generated/filesystem.php",
"generated/filter.php",
"generated/fpm.php",
"generated/ftp.php",
"generated/funchand.php",
"generated/gettext.php",
"generated/gmp.php",
"generated/gnupg.php",
"generated/hash.php",
"generated/ibase.php",
"generated/ibmDb2.php",
"generated/iconv.php",
"generated/image.php",
"generated/imap.php",
"generated/info.php",
"generated/inotify.php",
"generated/json.php",
"generated/ldap.php",
"generated/libxml.php",
"generated/lzf.php",
"generated/mailparse.php",
"generated/mbstring.php",
"generated/misc.php",
"generated/mysql.php",
"generated/mysqli.php",
"generated/network.php",
"generated/oci8.php",
"generated/opcache.php",
"generated/openssl.php",
"generated/outcontrol.php",
"generated/pcntl.php",
"generated/pcre.php",
"generated/pgsql.php",
"generated/posix.php",
"generated/ps.php",
"generated/pspell.php",
"generated/readline.php",
"generated/rnp.php",
"generated/rpminfo.php",
"generated/rrd.php",
"generated/sem.php",
"generated/session.php",
"generated/shmop.php",
"generated/sockets.php",
"generated/sodium.php",
"generated/solr.php",
"generated/spl.php",
"generated/sqlsrv.php",
"generated/ssdeep.php",
"generated/ssh2.php",
"generated/stream.php",
"generated/strings.php",
"generated/swoole.php",
"generated/uodbc.php",
"generated/uopz.php",
"generated/url.php",
"generated/var.php",
"generated/xdiff.php",
"generated/xml.php",
"generated/xmlrpc.php",
"generated/yaml.php",
"generated/yaz.php",
"generated/zip.php",
"generated/zlib.php"
],
"classmap": [
"lib/DateTime.php",
"lib/DateTimeImmutable.php",
"lib/Exceptions/",
"generated/Exceptions/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
"support": {
"issues": "https://github.com/thecodingmachine/safe/issues",
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
},
"funding": [
{
"url": "https://github.com/OskarStark",
"type": "github"
},
{
"url": "https://github.com/shish",
"type": "github"
},
{
"url": "https://github.com/silasjoisten",
"type": "github"
},
{
"url": "https://github.com/staabm",
"type": "github"
}
],
"time": "2026-02-04T18:08:13+00:00"
},
{
"name": "theseer/tokenizer",
"version": "2.0.1",
+301
View File
@@ -0,0 +1,301 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Settings
|--------------------------------------------------------------------------
|
| Set some default values. It is possible to add all defines that can be set
| in dompdf_config.inc.php. You can also override the entire config file.
|
*/
'show_warnings' => false, // Throw an Exception on warnings from dompdf
'public_path' => null, // Override the public path if needed
/*
* Dejavu Sans font is missing glyphs for converted entities, turn it off if you need to show and £.
*/
'convert_entities' => true,
'options' => [
/**
* The location of the DOMPDF font directory
*
* The location of the directory where DOMPDF will store fonts and font metrics
* Note: This directory must exist and be writable by the webserver process.
* *Please note the trailing slash.*
*
* Notes regarding fonts:
* Additional .afm font metrics can be added by executing load_font.php from command line.
*
* Only the original "Base 14 fonts" are present on all pdf viewers. Additional fonts must
* be embedded in the pdf file or the PDF may not display correctly. This can significantly
* increase file size unless font subsetting is enabled. Before embedding a font please
* review your rights under the font license.
*
* Any font specification in the source HTML is translated to the closest font available
* in the font directory.
*
* The pdf standard "Base 14 fonts" are:
* Courier, Courier-Bold, Courier-BoldOblique, Courier-Oblique,
* Helvetica, Helvetica-Bold, Helvetica-BoldOblique, Helvetica-Oblique,
* Times-Roman, Times-Bold, Times-BoldItalic, Times-Italic,
* Symbol, ZapfDingbats.
*/
'font_dir' => storage_path('fonts'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
/**
* The location of the DOMPDF font cache directory
*
* This directory contains the cached font metrics for the fonts used by DOMPDF.
* This directory can be the same as DOMPDF_FONT_DIR
*
* Note: This directory must exist and be writable by the webserver process.
*/
'font_cache' => storage_path('fonts'),
/**
* The location of a temporary directory.
*
* The directory specified must be writeable by the webserver process.
* The temporary directory is required to download remote images and when
* using the PDFLib back end.
*/
'temp_dir' => sys_get_temp_dir(),
/**
* ==== IMPORTANT ====
*
* dompdf's "chroot": Prevents dompdf from accessing system files or other
* files on the webserver. All local files opened by dompdf must be in a
* subdirectory of this directory. DO NOT set it to '/' since this could
* allow an attacker to use dompdf to read any files on the server. This
* should be an absolute path.
* This is only checked on command line call by dompdf.php, but not by
* direct class use like:
* $dompdf = new DOMPDF(); $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output();
*/
'chroot' => realpath(base_path()),
/**
* Protocol whitelist
*
* Protocols and PHP wrappers allowed in URIs, and the validation rules
* that determine if a resouce may be loaded. Full support is not guaranteed
* for the protocols/wrappers specified
* by this array.
*
* @var array
*/
'allowed_protocols' => [
'data://' => ['rules' => []],
'file://' => ['rules' => []],
'http://' => ['rules' => []],
'https://' => ['rules' => []],
],
/**
* Operational artifact (log files, temporary files) path validation
*/
'artifactPathValidation' => null,
/**
* @var string
*/
'log_output_file' => null,
/**
* Whether to enable font subsetting or not.
*/
'enable_font_subsetting' => false,
/**
* The PDF rendering backend to use
*
* Valid settings are 'PDFLib', 'CPDF' (the bundled R&OS PDF class), 'GD' and
* 'auto'. 'auto' will look for PDFLib and use it if found, or if not it will
* fall back on CPDF. 'GD' renders PDFs to graphic files.
* {@link * Canvas_Factory} ultimately determines which rendering class to
* instantiate based on this setting.
*
* Both PDFLib & CPDF rendering backends provide sufficient rendering
* capabilities for dompdf, however additional features (e.g. object,
* image and font support, etc.) differ between backends. Please see
* {@link PDFLib_Adapter} for more information on the PDFLib backend
* and {@link CPDF_Adapter} and lib/class.pdf.php for more information
* on CPDF. Also see the documentation for each backend at the links
* below.
*
* The GD rendering backend is a little different than PDFLib and
* CPDF. Several features of CPDF and PDFLib are not supported or do
* not make any sense when creating image files. For example,
* multiple pages are not supported, nor are PDF 'objects'. Have a
* look at {@link GD_Adapter} for more information. GD support is
* experimental, so use it at your own risk.
*
* @link http://www.pdflib.com
* @link http://www.ros.co.nz/pdf
* @link http://www.php.net/image
*/
'pdf_backend' => 'CPDF',
/**
* html target media view which should be rendered into pdf.
* List of types and parsing rules for future extensions:
* http://www.w3.org/TR/REC-html40/types.html
* screen, tty, tv, projection, handheld, print, braille, aural, all
* Note: aural is deprecated in CSS 2.1 because it is replaced by speech in CSS 3.
* Note, even though the generated pdf file is intended for print output,
* the desired content might be different (e.g. screen or projection view of html file).
* Therefore allow specification of content here.
*/
'default_media_type' => 'screen',
/**
* The default paper size.
*
* North America standard is "letter"; other countries generally "a4"
*
* @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.)
*/
'default_paper_size' => 'a4',
/**
* The default paper orientation.
*
* The orientation of the page (portrait or landscape).
*
* @var string
*/
'default_paper_orientation' => 'portrait',
/**
* The default font family
*
* Used if no suitable fonts can be found. This must exist in the font folder.
*
* @var string
*/
'default_font' => 'dejavu sans',
/**
* Image DPI setting
*
* This setting determines the default DPI setting for images and fonts. The
* DPI may be overridden for inline images by explictly setting the
* image's width & height style attributes (i.e. if the image's native
* width is 600 pixels and you specify the image's width as 72 points,
* the image will have a DPI of 600 in the rendered PDF. The DPI of
* background images can not be overridden and is controlled entirely
* via this parameter.
*
* For the purposes of DOMPDF, pixels per inch (PPI) = dots per inch (DPI).
* If a size in html is given as px (or without unit as image size),
* this tells the corresponding size in pt.
* This adjusts the relative sizes to be similar to the rendering of the
* html page in a reference browser.
*
* In pdf, always 1 pt = 1/72 inch
*
* Rendering resolution of various browsers in px per inch:
* Windows Firefox and Internet Explorer:
* SystemControl->Display properties->FontResolution: Default:96, largefonts:120, custom:?
* Linux Firefox:
* about:config *resolution: Default:96
* (xorg screen dimension in mm and Desktop font dpi settings are ignored)
*
* Take care about extra font/image zoom factor of browser.
*
* In images, <img> size in pixel attribute, img css style, are overriding
* the real image dimension in px for rendering.
*
* @var int
*/
'dpi' => 96,
/**
* Enable embedded PHP
*
* If this setting is set to true then DOMPDF will automatically evaluate embedded PHP contained
* within <script type="text/php"> ... </script> tags.
*
* ==== IMPORTANT ==== Enabling this for documents you do not trust (e.g. arbitrary remote html pages)
* is a security risk.
* Embedded scripts are run with the same level of system access available to dompdf.
* Set this option to false (recommended) if you wish to process untrusted documents.
* This setting may increase the risk of system exploit.
* Do not change this settings without understanding the consequences.
* Additional documentation is available on the dompdf wiki at:
* https://github.com/dompdf/dompdf/wiki
*
* @var bool
*/
'enable_php' => false,
/**
* Enable inline JavaScript
*
* If this setting is set to true then DOMPDF will automatically insert JavaScript code contained
* within <script type="text/javascript"> ... </script> tags as written into the PDF.
* NOTE: This is PDF-based JavaScript to be executed by the PDF viewer,
* not browser-based JavaScript executed by Dompdf.
*
* @var bool
*/
'enable_javascript' => true,
/**
* Enable remote file access
*
* If this setting is set to true, DOMPDF will access remote sites for
* images and CSS files as required.
*
* ==== IMPORTANT ====
* This can be a security risk, in particular in combination with isPhpEnabled and
* allowing remote html code to be passed to $dompdf = new DOMPDF(); $dompdf->load_html(...);
* This allows anonymous users to download legally doubtful internet content which on
* tracing back appears to being downloaded by your server, or allows malicious php code
* in remote html pages to be executed by your server with your account privileges.
*
* This setting may increase the risk of system exploit. Do not change
* this settings without understanding the consequences. Additional
* documentation is available on the dompdf wiki at:
* https://github.com/dompdf/dompdf/wiki
*
* @var bool
*/
'enable_remote' => false,
/**
* List of allowed remote hosts
*
* Each value of the array must be a valid hostname.
*
* This will be used to filter which resources can be loaded in combination with
* isRemoteEnabled. If enable_remote is FALSE, then this will have no effect.
*
* Leave to NULL to allow any remote host.
*
* @var array|null
*/
'allowed_remote_hosts' => null,
/**
* A ratio applied to the fonts height to be more like browsers' line height
*/
'font_height_ratio' => 1.1,
/**
* Use the HTML5 Lib parser
*
* @deprecated This feature is now always on in dompdf 2.x
*
* @var bool
*/
'enable_html5_parser' => true,
],
];
+31
View File
@@ -3227,3 +3227,34 @@ parameters:
identifier: argument.type
count: 1
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Billing/AdminInvoiceIndexTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Billing/AdminInvoiceIndexTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Billing/ExpireInvoicesTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Billing/InvoiceCreateTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/Billing/InvoiceCreateTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:get\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Billing/InvoiceCreateTest.php
+36
View File
@@ -576,3 +576,39 @@ export async function executePdErasure(id: number, adminUserId?: number): Promis
const { data } = await apiClient.post<EraseSubjectResult>(`/api/admin/pd-subject-requests/${id}/erase`, payload);
return data;
}
// --- Оплата по счёту (Этап 1): список счетов + ручная отметка оплаты ---
export interface AdminInvoiceRow {
id: number;
invoice_number: string;
amount_total: string;
status: string;
issued_at: string;
expires_at: string | null;
tenant_id: number;
tenant_name: string | null;
payer_name: string | null;
}
export interface ListAdminInvoicesParams {
status?: string;
search?: string;
page?: number;
per_page?: number;
}
export interface ListAdminInvoicesResponse {
data: AdminInvoiceRow[];
meta: { current_page: number; last_page: number; total: number; per_page: number };
}
export async function listAdminInvoices(params: ListAdminInvoicesParams = {}): Promise<ListAdminInvoicesResponse> {
const { data } = await apiClient.get<ListAdminInvoicesResponse>('/api/admin/invoices', { params });
return data;
}
export async function markInvoicePaid(id: number): Promise<void> {
await ensureCsrfCookie();
await apiClient.post(`/api/admin/invoices/${id}/mark-paid`);
}
+22 -1
View File
@@ -73,7 +73,19 @@ export interface BillingInvoice {
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 — пагинированная история транзакций. */
@@ -82,12 +94,21 @@ export async function getTransactions(params: { page?: number; type?: string }):
return data;
}
/** GET /api/billing/invoices — счета тенанта (real-but-empty до Б-1). */
/** 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 (мгновенное зачисление);
@@ -61,7 +61,7 @@ defineExpose({ load, invoices });
</v-alert>
<div v-else-if="invoices.length === 0" class="empty pa-8 text-center text-medium-emphasis">
Счета появятся после первой оплаты.
Здесь появятся выставленные вами счета на оплату.
</div>
<ul v-else class="invoices-list pa-2 ma-0">
@@ -72,9 +72,30 @@ defineExpose({ load, invoices });
<span class="sub">{{ statusLabel(inv.status) }}</span>
</span>
<span class="inv-amount num">{{ formatPlain(Number(inv.amount_total)) }}</span>
<v-btn variant="text" size="small" prepend-icon="mdi-file-pdf-box" :disabled="!inv.has_pdf">
PDF
</v-btn>
<span class="inv-actions">
<v-btn
variant="text"
size="small"
prepend-icon="mdi-file-pdf-box"
:href="inv.pdf_url ?? undefined"
target="_blank"
:disabled="!inv.has_pdf"
:data-testid="`inv-pdf-${inv.id}`"
>
Счёт
</v-btn>
<v-btn
v-if="inv.has_act"
variant="text"
size="small"
prepend-icon="mdi-file-document-check-outline"
:href="inv.act_url ?? undefined"
target="_blank"
:data-testid="`inv-act-${inv.id}`"
>
Акт
</v-btn>
</span>
</li>
</ul>
</v-card>
@@ -141,4 +162,9 @@ defineExpose({ load, invoices });
font-weight: 500;
color: #081319;
}
.inv-actions {
display: flex;
gap: 4px;
justify-content: flex-end;
}
</style>
@@ -1,21 +1,24 @@
<script setup lang="ts">
/**
* TopupDialog диалог пополнения рублёвого баланса (audit E1).
* TopupDialog диалог пополнения рублёвого баланса.
*
* MVP-stub: POST /api/billing/topup кредитует баланс немедленно (без
* платёжного шлюза реальная оплата post-Б-1). При успехе эмитит
* `success` с новым балансом и закрывается.
* Два способа:
* «Карта» POST /api/billing/topup (заглушка мгновенного зачисления ИЛИ
* редирект на ЮKassa, если флаг billing_yookassa_enabled ВКЛ).
* «По счёту» (для юрлиц) POST /api/billing/invoices: формирует PDF-счёт по
* реквизитам тенанта, баланс пополнится после ручной отметки оплаты админом.
*/
import { ref, computed, watch } from 'vue';
import { topup } from '../../api/billing';
import { topup, createInvoice } from '../../api/billing';
import { extractErrorMessage, extractValidationErrors } from '../../api/client';
import { redirectTo } from '../../utils/redirect';
const model = defineModel<boolean>({ required: true });
const emit = defineEmits<{ success: [balanceRub: string] }>();
const emit = defineEmits<{ success: [balanceRub: string]; invoiced: [invoiceNumber: string] }>();
const PRESETS = [1000, 5000, 10000, 25000];
const method = ref<'card' | 'invoice'>('card');
const amount = ref<number | null>(null);
const submitting = ref(false);
const errorMsg = ref<string | null>(null);
@@ -29,12 +32,14 @@ const amountError = computed<string | null>(() => {
const canSubmit = computed(() => Number.isFinite(amount.value) && amountError.value === null && !submitting.value);
// Сброс состояния при каждом открытии диалога (паттерн ReminderDialog/
// NewDealDialog) нет префилла прошлой суммы и нет всплытия устаревшей ошибки.
const submitLabel = computed(() => (method.value === 'invoice' ? 'Сформировать счёт' : 'Пополнить'));
// Сброс состояния при каждом открытии диалога нет префилла прошлой суммы/ошибки.
watch(model, (open) => {
if (open) {
amount.value = null;
errorMsg.value = null;
method.value = 'card';
}
});
@@ -42,11 +47,26 @@ function setPreset(value: number): void {
amount.value = value;
}
function openPdf(url: string): void {
if (typeof window !== 'undefined' && typeof window.open === 'function') {
window.open(url, '_blank');
}
}
async function submit(): Promise<void> {
if (!canSubmit.value || amount.value === null) return;
submitting.value = true;
errorMsg.value = null;
try {
if (method.value === 'invoice') {
const invoice = await createInvoice(amount.value);
openPdf(invoice.pdf_url);
emit('invoiced', invoice.invoice_number);
model.value = false;
amount.value = null;
return;
}
const res = await topup(amount.value);
// Реальный шлюз (флаг ВКЛ): редирект на страницу оплаты ЮKassa.
if (res.confirmation_url) {
@@ -71,7 +91,7 @@ function close(): void {
errorMsg.value = null;
}
defineExpose({ amount, submit, canSubmit, errorMsg });
defineExpose({ method, amount, submit, canSubmit, errorMsg });
</script>
<template>
@@ -79,6 +99,18 @@ defineExpose({ amount, submit, canSubmit, errorMsg });
<v-card>
<v-card-title class="text-h6">Пополнить баланс</v-card-title>
<v-card-text>
<v-btn-toggle
v-model="method"
mandatory
density="comfortable"
color="primary"
class="mb-4"
data-testid="topup-method"
>
<v-btn value="card">Картой</v-btn>
<v-btn value="invoice">По счёту (для юрлиц)</v-btn>
</v-btn-toggle>
<v-text-field
v-model.number="amount"
type="number"
@@ -95,8 +127,16 @@ defineExpose({ amount, submit, canSubmit, errorMsg });
</v-chip>
</div>
<v-alert type="info" variant="tonal" density="compact" class="mt-2">
Платёжный шлюз подключается после регистрации юр. лица на текущем этапе баланс пополняется сразу.
<v-alert
v-if="method === 'invoice'"
type="info"
variant="tonal"
density="compact"
class="mt-2"
>
Счёт сформируется по реквизитам вашей компании. Оплатите его банковским переводом
баланс пополнится после поступления денег. Закрывающий документ (Акт) сформируется
автоматически после оплаты.
</v-alert>
<v-alert v-if="errorMsg" type="error" variant="tonal" density="compact" class="mt-3" role="alert">
@@ -107,7 +147,7 @@ defineExpose({ amount, submit, canSubmit, errorMsg });
<v-spacer />
<v-btn variant="text" :disabled="submitting" @click="close">Отмена</v-btn>
<v-btn color="primary" variant="flat" :loading="submitting" :disabled="!canSubmit" @click="submit">
Пополнить
{{ submitLabel }}
</v-btn>
</v-card-actions>
</v-card>
+1
View File
@@ -29,6 +29,7 @@ const navItems: NavItem[] = [
{ title: 'Тенанты', icon: 'mdi-account-group-outline', to: '/admin/tenants' },
{ title: 'Лиды', icon: 'mdi-target', to: '/admin/leads' },
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/admin/billing' },
{ title: 'Счета', icon: 'mdi-file-document-outline', to: '/admin/invoices' },
{ title: 'Тарифная сетка', icon: 'mdi-tag-arrow-right', to: '/admin/pricing-tiers' },
{ title: 'Цены поставщиков', icon: 'mdi-currency-rub', to: '/admin/supplier-prices' },
{ title: 'Инциденты', icon: 'mdi-alert-outline', to: '/admin/incidents' },
+6
View File
@@ -222,6 +222,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('../views/admin/AdminBillingView.vue'),
meta: { layout: 'admin', title: 'Биллинг', requiresAuth: true, devIndex: 23, devLabel: 'Admin Billing' },
},
{
path: '/admin/invoices',
name: 'admin-invoices',
component: () => import('../views/admin/AdminInvoicesView.vue'),
meta: { layout: 'admin', title: 'Счета', requiresAuth: true, devLabel: 'Admin Invoices' },
},
{
path: '/admin/leads',
name: 'admin-leads',
+22 -5
View File
@@ -9,7 +9,7 @@
* Sprint 5C (E4): pending-баннер убран платёжного шлюза нет (Б-1), реального состояния «платёж в обработке» в БД не существует.
* TopupDialog «Пополнить баланс» Task 5 (E1).
*/
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, nextTick } from 'vue';
import BalanceCard from '../components/billing/BalanceCard.vue';
import TierPricesPanel from '../components/billing/TierPricesPanel.vue';
import TransactionsTable from '../components/billing/TransactionsTable.vue';
@@ -21,7 +21,7 @@ import { getWallet, type Wallet } from '../api/billing';
import { extractErrorMessage } from '../api/client';
import { useTenantStore } from '../stores/tenantStore';
const activeView = ref<'overview' | 'charges'>('overview');
const activeView = ref<'overview' | 'charges' | 'invoices'>('overview');
const tenant = useTenantStore();
const wallet = ref<Wallet | null>(null);
@@ -32,6 +32,9 @@ const topupSnackbar = ref(false);
// Возврат с платёжной страницы шлюза (?topup=return): баланс зачислится по webhook.
const paymentReturn = ref(false);
const txTableRef = ref<InstanceType<typeof TransactionsTable> | null>(null);
const invoicesTableRef = ref<InstanceType<typeof InvoicesTable> | null>(null);
const invoiceSnackbar = ref(false);
const invoiceMsg = ref('');
const walletRub = computed(() => Number(wallet.value?.balance_rub ?? 0));
const affordableLeads = computed(() => wallet.value?.affordable_leads ?? 0);
@@ -65,6 +68,16 @@ async function onTopupSuccess(): Promise<void> {
txTableRef.value?.refresh();
}
async function onInvoiced(invoiceNumber: string): Promise<void> {
// Счёт выставлен переключаемся на вкладку «Счета», обновляем список и тост.
topupOpen.value = false;
invoiceMsg.value = `Счёт ${invoiceNumber} сформирован. Файл открыт в новой вкладке.`;
invoiceSnackbar.value = true;
activeView.value = 'invoices';
await nextTick();
await invoicesTableRef.value?.load();
}
onMounted(() => {
paymentReturn.value = new URLSearchParams(window.location.search).get('topup') === 'return';
void loadWallet();
@@ -105,6 +118,7 @@ defineExpose({ loadWallet, wallet, topupOpen });
<v-tabs v-model="activeView" color="primary" class="mt-4">
<v-tab value="overview">Обзор</v-tab>
<v-tab value="charges">Списания</v-tab>
<v-tab value="invoices">Счета</v-tab>
</v-tabs>
<v-tabs-window v-model="activeView">
@@ -132,19 +146,22 @@ defineExpose({ loadWallet, wallet, topupOpen });
<TierPricesPanel :tiers="tiersPreview" :current-tier-no="currentTierNo" />
<TransactionsTable ref="txTableRef" />
<InvoicesTable />
</template>
</v-tabs-window-item>
<v-tabs-window-item value="charges">
<ChargesTab />
</v-tabs-window-item>
<v-tabs-window-item value="invoices">
<InvoicesTable ref="invoicesTableRef" />
</v-tabs-window-item>
</v-tabs-window>
<TopupDialog v-model="topupOpen" @success="onTopupSuccess" />
<TopupDialog v-model="topupOpen" @success="onTopupSuccess" @invoiced="onInvoiced" />
<v-snackbar v-model="topupSnackbar" color="success" :timeout="4000"> Баланс пополнен. </v-snackbar>
<v-snackbar v-model="invoiceSnackbar" color="success" :timeout="6000">{{ invoiceMsg }}</v-snackbar>
</v-container>
</template>
@@ -0,0 +1,262 @@
<script setup lang="ts">
/**
* AdminInvoicesView SaaS-admin экран «Счета» (Этап 1 «оплата по счёту»).
* Серверная пагинация/поиск/фильтр по статусу. Кнопка «Отметить оплаченным»
* у выставленных счетов открывает диалог подтверждения markInvoicePaid
* зачисление баланса + формирование Акта на бэкенде.
*/
import { ref, onMounted, watch } from 'vue';
import {
listAdminInvoices,
markInvoicePaid,
type AdminInvoiceRow,
} from '../../api/admin';
import { extractErrorMessage } from '../../api/client';
const STATUS_LABELS: Record<string, string> = {
draft: 'Черновик',
issued: 'Выставлен',
paid: 'Оплачен',
overdue: 'Просрочен',
cancelled: 'Отменён',
};
const STATUS_COLORS: Record<string, string> = {
issued: 'info',
paid: 'success',
overdue: 'warning',
cancelled: 'grey',
draft: 'grey',
};
const STATUS_FILTERS = [
{ value: '', title: 'Все статусы' },
{ value: 'issued', title: 'Выставленные' },
{ value: 'paid', title: 'Оплаченные' },
{ value: 'overdue', title: 'Просроченные' },
{ value: 'cancelled', title: 'Отменённые' },
];
const rows = ref<AdminInvoiceRow[]>([]);
const loading = ref(true);
const loadError = ref<string | null>(null);
const page = ref(1);
const perPage = ref(25);
const total = ref(0);
const lastPage = ref(1);
const search = ref('');
const filterStatus = ref('');
const confirmOpen = ref(false);
const confirmRow = ref<AdminInvoiceRow | null>(null);
const marking = ref(false);
const snackbar = ref(false);
const snackMsg = ref('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
function statusLabel(s: string): string {
return STATUS_LABELS[s] ?? s;
}
function statusColor(s: string): string {
return STATUS_COLORS[s] ?? 'grey';
}
function formatDate(iso: string | null): string {
return iso ? new Date(iso).toLocaleDateString('ru-RU', { timeZone: 'Europe/Moscow' }) : '—';
}
function formatAmount(v: string): string {
return new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 2 }).format(Number(v));
}
async function load(): Promise<void> {
loading.value = true;
loadError.value = null;
try {
const res = await listAdminInvoices({
status: filterStatus.value || undefined,
search: search.value || undefined,
page: page.value,
per_page: perPage.value,
});
rows.value = res.data;
total.value = res.meta.total;
lastPage.value = res.meta.last_page;
} catch (e) {
loadError.value = extractErrorMessage(e, 'Не удалось загрузить счета.');
} finally {
loading.value = false;
}
}
function goPage(p: number): void {
page.value = p;
void load();
}
watch(search, () => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
page.value = 1;
void load();
}, 400);
});
watch(filterStatus, () => {
page.value = 1;
void load();
});
function askMarkPaid(row: AdminInvoiceRow): void {
confirmRow.value = row;
confirmOpen.value = true;
}
async function doMarkPaid(): Promise<void> {
if (confirmRow.value === null) return;
marking.value = true;
try {
await markInvoicePaid(confirmRow.value.id);
snackMsg.value = `Счёт ${confirmRow.value.invoice_number} отмечен оплаченным, баланс зачислен.`;
snackbar.value = true;
confirmOpen.value = false;
confirmRow.value = null;
await load();
} catch (e) {
snackMsg.value = extractErrorMessage(e, 'Не удалось отметить оплату.');
snackbar.value = true;
} finally {
marking.value = false;
}
}
onMounted(load);
defineExpose({ rows, page, perPage, total, search, filterStatus, goPage, load });
</script>
<template>
<v-container class="invoices-admin" fluid>
<div class="page-head mb-4">
<h1 class="text-h5 page-title ma-0">Счета</h1>
</div>
<div class="filters mb-4">
<v-text-field
v-model="search"
label="Поиск по номеру, клиенту, плательщику"
density="comfortable"
hide-details
clearable
prepend-inner-icon="mdi-magnify"
style="max-width: 420px"
/>
<v-select
v-model="filterStatus"
:items="STATUS_FILTERS"
item-title="title"
item-value="value"
label="Статус"
density="comfortable"
hide-details
style="max-width: 220px"
/>
</div>
<v-card variant="outlined">
<div v-if="loading" class="py-10 d-flex justify-center">
<v-progress-circular indeterminate color="primary" />
</div>
<v-alert v-else-if="loadError" type="error" variant="tonal" class="ma-4" role="alert">
{{ loadError }}
</v-alert>
<div v-else-if="rows.length === 0" class="py-10 text-center text-medium-emphasis">
Счетов не найдено.
</div>
<v-table v-else>
<thead>
<tr>
<th>Дата</th>
<th>Номер</th>
<th>Клиент</th>
<th>Плательщик</th>
<th class="text-right">Сумма, </th>
<th>Статус</th>
<th>Оплатить до</th>
<th class="text-right">Действие</th>
</tr>
</thead>
<tbody>
<tr v-for="r in rows" :key="r.id">
<td class="num">{{ formatDate(r.issued_at) }}</td>
<td class="num">{{ r.invoice_number }}</td>
<td>{{ r.tenant_name ?? '—' }}</td>
<td>{{ r.payer_name ?? '—' }}</td>
<td class="text-right num">{{ formatAmount(r.amount_total) }}</td>
<td><v-chip :color="statusColor(r.status)" size="small" variant="tonal">{{ statusLabel(r.status) }}</v-chip></td>
<td class="num">{{ formatDate(r.expires_at) }}</td>
<td class="text-right">
<v-btn
v-if="r.status === 'issued' || r.status === 'overdue'"
color="success"
size="small"
variant="flat"
:data-testid="`mark-paid-${r.id}`"
@click="askMarkPaid(r)"
>
Отметить оплаченным
</v-btn>
</td>
</tr>
</tbody>
</v-table>
<div v-if="lastPage > 1" class="d-flex justify-center py-4">
<v-pagination
:model-value="page"
:length="lastPage"
:total-visible="7"
@update:model-value="goPage"
/>
</div>
</v-card>
<v-dialog v-model="confirmOpen" max-width="460">
<v-card v-if="confirmRow">
<v-card-title class="text-h6">Подтверждение оплаты</v-card-title>
<v-card-text>
Отметить счёт <b>{{ confirmRow.invoice_number }}</b> на сумму
<b>{{ formatAmount(confirmRow.amount_total) }} </b>
(клиент: {{ confirmRow.tenant_name ?? confirmRow.payer_name ?? '—' }}) как оплаченный?
Баланс клиента будет пополнен, сформируется Акт.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" :disabled="marking" @click="confirmOpen = false">Отмена</v-btn>
<v-btn color="success" variant="flat" :loading="marking" @click="doMarkPaid">Подтверждаю</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar v-model="snackbar" :timeout="5000" color="success">{{ snackMsg }}</v-snackbar>
</v-container>
</template>
<style scoped>
.page-title {
font-variation-settings: 'opsz' 24;
letter-spacing: -0.015em;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
}
</style>
@@ -276,7 +276,13 @@
<div class="mt-3">
<span class="text-caption">Дни недели приёма</span>
<v-btn-toggle v-model="selectedDays" multiple density="comfortable" class="mt-1">
<v-btn-toggle
v-model="selectedDays"
multiple
density="comfortable"
class="mt-1 day-toggle"
selected-class="day-active"
>
<v-btn v-for="(day, i) in dayLabels" :key="i" :value="i">{{ day }}</v-btn>
</v-btn-toggle>
<div class="mt-1">
@@ -436,9 +442,9 @@ const reqSaving = ref(false);
const reqGeneralError = ref<string | null>(null);
const subjectTypeItems = [
{ value: 'individual', title: 'Физлицо' },
{ value: 'individual', title: 'Физическое лицо' },
{ value: 'sole_proprietor', title: 'ИП' },
{ value: 'legal_entity', title: 'Юрлицо' },
{ value: 'legal_entity', title: 'Юридическое лицо' },
];
// Зеркало RequisitesService::isLightComplete тип лица + имя + телефон (+ ИНН для юр/ИП).
@@ -767,4 +773,12 @@ defineExpose({
border-color: currentColor;
opacity: 1;
}
/* Выбранные дни недели — сплошная зелёная заливка, как в ProjectDetailsDrawer (.pdd-day.active) */
.day-toggle :deep(.v-btn.day-active) {
background-color: #0f6e56;
color: #fff;
}
.day-toggle :deep(.v-btn.day-active .v-btn__overlay) {
opacity: 0;
}
</style>
@@ -37,9 +37,9 @@ const lookupMessage = ref('');
const lookupError = ref(false);
const subjectTypes = [
{ value: 'individual', label: 'Физлицо' },
{ value: 'individual', label: 'Физическое лицо' },
{ value: 'sole_proprietor', label: 'ИП' },
{ value: 'legal_entity', label: 'Юрлицо' },
{ value: 'legal_entity', label: 'Юридическое лицо' },
];
const requiresInn = computed(
@@ -49,8 +49,10 @@ const requiresInn = computed(
const isLegalEntity = computed(() => form.subject_type === 'legal_entity');
const isSoleProprietor = computed(() => form.subject_type === 'sole_proprietor');
// Блок платёжных реквизитов виден, как только выбран тип лица.
const showPayment = computed(() => form.subject_type !== null);
// Блок платёжных реквизитов виден для ИП и юрлица; у физлица банковских реквизитов нет.
const showPayment = computed(
() => form.subject_type !== null && form.subject_type !== 'individual',
);
// КПП только юрлицо; ОГРН/ОГРНИП и юр.адрес юрлицо и ИП; банк всегда (когда showPayment).
const showKpp = computed(() => isLegalEntity.value);
const showOgrn = computed(() => isLegalEntity.value || isSoleProprietor.value);
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<style>
body { font-family: 'dejavu sans'; font-size: 11px; color: #000; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #000; padding: 4px; text-align: left; }
th { background: #eee; }
h1 { font-size: 15px; margin: 12px 0; }
.right { text-align: right; }
.sign { margin-top: 30px; }
.sign td { border: none; padding: 8px 4px; }
</style>
</head>
<body>
<h1>Акт {{ $act->upd_number }} от {{ \Illuminate\Support\Carbon::parse($act->issued_at)->format('d.m.Y') }}</h1>
<p><b>Исполнитель:</b> {{ $seller->name }}, ИНН {{ $seller->inn }}{{ $seller->kpp ? ', КПП '.$seller->kpp : '' }}</p>
<p><b>Заказчик:</b> {{ $act->buyer_name }}, ИНН {{ $act->buyer_inn }}{{ $act->buyer_kpp ? ', КПП '.$act->buyer_kpp : '' }}</p>
<p><b>Основание:</b> счёт {{ $invoiceNumber }}</p>
<table>
<tr><th></th><th>Наименование услуги</th><th>Сумма</th></tr>
<tr><td>1</td><td>Оплата генерации рекламных лидов</td><td>{{ number_format((float) $act->amount_total, 2, '.', ' ') }} </td></tr>
</table>
<p class="right"><b>Всего оказано услуг на сумму: {{ number_format((float) $act->amount_total, 2, '.', ' ') }} </b><br>Без НДС</p>
<p>Вышеперечисленные услуги оказаны полностью и в срок. Заказчик претензий по объёму, качеству и срокам оказания услуг не имеет.</p>
<table class="sign">
<tr>
<td style="width:50%">Исполнитель<br><br>_______________ / {{ $seller->director_name ?? $seller->name }}</td>
<td style="width:50%">Заказчик<br><br>_______________ / {{ $act->buyer_name }}</td>
</tr>
</table>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<style>
body { font-family: 'dejavu sans'; font-size: 11px; color: #000; }
table { width: 100%; border-collapse: collapse; }
.bank td { border: 1px solid #000; padding: 4px; vertical-align: top; }
.items th, .items td { border: 1px solid #000; padding: 4px; text-align: left; }
.items th { background: #eee; }
h1 { font-size: 15px; margin: 12px 0; }
.right { text-align: right; }
.muted { color: #555; }
</style>
</head>
<body>
<table class="bank">
<tr>
<td rowspan="2" style="width:55%">{{ $seller->bank_name }}</td>
<td style="width:15%">БИК</td>
<td>{{ $seller->bank_bik }}</td>
</tr>
<tr>
<td>Сч. </td>
<td>{{ $seller->bank_corr }}</td>
</tr>
<tr>
<td>Получатель<br>{{ $seller->name }}<br>ИНН {{ $seller->inn }} {{ $seller->kpp ? 'КПП '.$seller->kpp : '' }}</td>
<td>Сч. </td>
<td>{{ $seller->bank_account }}</td>
</tr>
</table>
<h1>Счёт на оплату {{ $invoice->invoice_number }} от {{ \Illuminate\Support\Carbon::parse($invoice->issued_at)->format('d.m.Y') }}</h1>
<p><b>Поставщик (Исполнитель):</b> {{ $seller->name }}, ИНН {{ $seller->inn }}{{ $seller->kpp ? ', КПП '.$seller->kpp : '' }}{{ $seller->legal_address ? ', '.$seller->legal_address : '' }}</p>
<p><b>Покупатель (Заказчик):</b> {{ $invoice->payer_name }}, ИНН {{ $invoice->payer_inn }}{{ $invoice->payer_kpp ? ', КПП '.$invoice->payer_kpp : '' }}{{ $invoice->payer_address ? ', '.$invoice->payer_address : '' }}</p>
<table class="items">
<tr><th></th><th>Наименование</th><th>Кол-во</th><th>Ед.</th><th>Цена</th><th>Сумма</th></tr>
@foreach($items as $i => $it)
<tr>
<td>{{ $i + 1 }}</td>
<td>{{ $it->name }}</td>
<td>{{ (int) $it->quantity }}</td>
<td>{{ $it->unit }}</td>
<td>{{ number_format((float) $it->price, 2, '.', ' ') }}</td>
<td>{{ number_format((float) $it->amount_total, 2, '.', ' ') }}</td>
</tr>
@endforeach
</table>
<p class="right"><b>Итого: {{ number_format((float) $invoice->amount_total, 2, '.', ' ') }} </b><br>Без НДС</p>
<p><b>Назначение платежа:</b> {{ $invoice->payment_purpose }}</p>
<p class="muted">Оплатить до: {{ \Illuminate\Support\Carbon::parse($invoice->expires_at)->format('d.m.Y') }}</p>
</body>
</html>
+8
View File
@@ -104,6 +104,14 @@ Schedule::command('billing:preflight-sweep')
->onSuccess(fn () => $hb->recordRunResult('billing:preflight-sweep', true, null, null))
->onFailure(fn () => $hb->recordRunResult('billing:preflight-sweep', false, 'Command failed', null));
// Этап 1 «оплата по счёту»: просроченные неоплаченные счета → overdue.
// 03:40 МСК — после ночных ретеншен-задач, вне пиковых часов.
Schedule::command('invoices:expire')
->dailyAt('03:40')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('invoices:expire', true, null, null))
->onFailure(fn () => $hb->recordRunResult('invoices:expire', false, 'Command failed', null));
// Billing v2 Spec C §3.7: повторные письма заморозки (reminder +1д, final +3д).
// Идёт ПОСЛЕ основного sweep — если sweep только что заморозил тенанта, окно reminder
// (24h+) ещё не открылось, повторного письма в тот же день не будет (correct).
+9
View File
@@ -139,6 +139,11 @@ Route::middleware(['saas-admin', 'admin-db'])->group(function () {
// SaaS-admin → Биллинг: aggregates пополнений/списаний за текущий месяц.
Route::get('/api/admin/billing', 'App\Http\Controllers\Api\AdminBillingController@index');
// SaaS-admin → Счета: список выставленных счетов + ручная отметка оплаты (Этап 1).
Route::get('/api/admin/invoices', 'App\Http\Controllers\Api\AdminInvoiceController@index');
Route::post('/api/admin/invoices/{id}/mark-paid', 'App\Http\Controllers\Api\AdminInvoiceController@markPaid')
->whereNumber('id');
// Sprint 3D (G4): SaaS-admin billing row-actions — приостановка/возврат/смена тарифа.
Route::get('/api/admin/billing/tariff-plans', 'App\Http\Controllers\Api\AdminBillingController@tariffPlans');
Route::patch('/api/admin/billing/tenants/{id}/status', 'App\Http\Controllers\Api\AdminBillingController@updateStatus')
@@ -238,6 +243,9 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing')->group(fun
Route::get('/balance-status', 'App\Http\Controllers\Api\BillingController@balanceStatus');
Route::get('/transactions', 'App\Http\Controllers\Api\BillingController@transactions');
Route::get('/invoices', 'App\Http\Controllers\Api\BillingController@invoices');
Route::post('/invoices', 'App\Http\Controllers\Api\InvoiceController@store');
Route::get('/invoices/{id}/pdf', 'App\Http\Controllers\Api\InvoiceController@pdf')->whereNumber('id');
Route::get('/invoices/{id}/act', 'App\Http\Controllers\Api\InvoiceController@act')->whereNumber('id');
});
// API-ключи тенанта (audit D2/D3/J5). RLS на api_keys требует tenant middleware.
@@ -381,6 +389,7 @@ Route::view('/import', 'welcome'); // Sprint 4 — CSV-импорт истори
Route::view('/admin', 'welcome');
Route::view('/admin/tenants', 'welcome');
Route::view('/admin/billing', 'welcome');
Route::view('/admin/invoices', 'welcome');
Route::view('/admin/incidents', 'welcome');
Route::view('/admin/system', 'welcome');
Route::view('/admin/pricing-tiers', 'welcome');
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
use App\Models\LegalEntity;
use App\Models\SaasInvoice;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Storage;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
function adminSeedInvoice(int $tenantId, int $legalEntityId, string $status, string $amount, string $number): SaasInvoice
{
return SaasInvoice::create([
'tenant_id' => $tenantId, 'legal_entity_id' => $legalEntityId, 'invoice_number' => $number,
'payer_type' => 'legal', 'payer_name' => 'ООО Клиент', 'payer_inn' => '5000000000',
'amount_net' => $amount, 'amount_total' => $amount, 'status' => $status,
'issued_at' => now(), 'expires_at' => now()->addDays(5),
]);
}
it('GET /api/admin/invoices отдаёт выставленные счета с пагинацией', function () {
$tenant = Tenant::factory()->create();
$le = LegalEntity::create(['code' => 'al_'.uniqid(), 'name' => 'ИП', 'legal_form' => 'IP', 'inn' => '770000000010', 'is_default' => true]);
adminSeedInvoice($tenant->id, $le->id, 'issued', '100.00', 'СЧ-2026-01001');
adminSeedInvoice($tenant->id, $le->id, 'issued', '200.00', 'СЧ-2026-01002');
adminSeedInvoice($tenant->id, $le->id, 'paid', '300.00', 'СЧ-2026-01003');
$this->getJson('/api/admin/invoices?status=issued')
->assertOk()
->assertJsonStructure(['data' => [['id', 'invoice_number', 'amount_total', 'status', 'tenant_id']], 'meta' => ['total']])
->assertJsonPath('meta.total', 2);
});
it('POST /api/admin/invoices/{id}/mark-paid зачисляет баланс и ставит paid', function () {
Storage::fake('local');
$tenant = Tenant::factory()->create(['balance_rub' => '0.00']);
User::factory()->create(['tenant_id' => $tenant->id]);
$le = LegalEntity::create(['code' => 'al2_'.uniqid(), 'name' => 'ИП', 'legal_form' => 'IP', 'inn' => '770000000011', 'is_default' => true]);
$invoice = adminSeedInvoice($tenant->id, $le->id, 'issued', '700.00', 'СЧ-2026-01010');
$this->postJson("/api/admin/invoices/{$invoice->id}/mark-paid")->assertOk();
expect((string) $tenant->fresh()->balance_rub)->toBe('700.00')
->and(SaasInvoice::find($invoice->id)->status)->toBe('paid');
});
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use App\Models\LegalEntity;
use App\Models\SaasInvoice;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(DatabaseTransactions::class);
function expSeed(int $tenantId, int $leId, string $status, string $number, $expiresAt): SaasInvoice
{
return SaasInvoice::create([
'tenant_id' => $tenantId, 'legal_entity_id' => $leId, 'invoice_number' => $number,
'payer_type' => 'legal', 'amount_net' => '100.00', 'amount_total' => '100.00',
'status' => $status, 'issued_at' => now()->subDays(10), 'expires_at' => $expiresAt,
]);
}
it('помечает overdue только просроченные неоплаченные счета', function () {
$t = Tenant::factory()->create();
$le = LegalEntity::create(['code' => 'exp_'.uniqid(), 'name' => 'ИП', 'legal_form' => 'IP', 'inn' => '770000000020']);
$stale = expSeed($t->id, $le->id, 'issued', 'СЧ-2026-02001', now()->subDay());
$fresh = expSeed($t->id, $le->id, 'issued', 'СЧ-2026-02002', now()->addDay());
$paid = expSeed($t->id, $le->id, 'paid', 'СЧ-2026-02003', now()->subDay());
$this->artisan('invoices:expire')->assertExitCode(0);
expect(SaasInvoice::find($stale->id)->status)->toBe('overdue')
->and(SaasInvoice::find($fresh->id)->status)->toBe('issued')
->and(SaasInvoice::find($paid->id)->status)->toBe('paid');
});
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
use App\Models\LegalEntity;
use App\Models\SaasInvoice;
use App\Models\Tenant;
use App\Models\TenantRequisites;
use App\Models\User;
use App\Services\Billing\Invoice\InvoiceService;
use App\Services\Billing\Invoice\RequisitesIncompleteException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Storage;
uses(DatabaseTransactions::class);
function makeSellerLe(): LegalEntity
{
return LegalEntity::create([
'code' => 'seller_'.uniqid(), 'name' => 'ИП Лидерра', 'legal_form' => 'IP',
'inn' => '770000000001', 'bank_name' => 'ВТБ', 'bank_bik' => '044525187',
'bank_account' => '40802810000000000001', 'bank_corr' => '30101810700000000187',
'is_default' => true,
]);
}
function makeClientRequisites(int $tenantId): TenantRequisites
{
return TenantRequisites::create([
'tenant_id' => $tenantId,
'subject_type' => 'legal_entity',
'contact_name' => 'Иван Клиентов',
'contact_phone' => '+79150000000',
'inn' => '5000000000',
'legal_name' => 'ООО Клиент',
'kpp' => '500001001',
'legal_address' => 'г. Москва, ул. Пример, 1',
'bank_account' => '40702810000000000002',
]);
}
it('создаёт счёт issued с позицией, без НДС, номером и PDF', function () {
Storage::fake('local');
$tenant = Tenant::factory()->create();
makeSellerLe();
makeClientRequisites($tenant->id);
$invoice = app(InvoiceService::class)->create($tenant->id, '1500.00', null);
expect($invoice->status)->toBe(SaasInvoice::STATUS_ISSUED)
->and((string) $invoice->amount_total)->toBe('1500.00')
->and((float) $invoice->vat_amount)->toBe(0.0)
->and($invoice->invoice_number)->toStartWith('СЧ-')
->and($invoice->pdf_path)->not->toBeNull()
->and($invoice->payer_name)->toBe('ООО Клиент')
->and($invoice->items()->count())->toBe(1)
->and($invoice->payment_purpose)->toContain($invoice->invoice_number);
Storage::disk('local')->assertExists($invoice->pdf_path);
});
it('бросает доменную ошибку если реквизиты клиента не заполнены', function () {
$tenant = Tenant::factory()->create();
makeSellerLe();
app(InvoiceService::class)->create($tenant->id, '1500.00', null);
})->throws(RequisitesIncompleteException::class);
it('POST /api/billing/invoices создаёт счёт и возвращает 201 с pdf-ссылкой', function () {
Storage::fake('local');
$tenant = Tenant::factory()->create();
makeSellerLe();
makeClientRequisites($tenant->id);
$this->actingAs(User::factory()->create(['tenant_id' => $tenant->id]));
$this->postJson('/api/billing/invoices', ['amount_rub' => 2000])
->assertStatus(201)
->assertJsonStructure(['invoice' => ['id', 'invoice_number', 'amount_total', 'pdf_url']]);
});
it('POST /api/billing/invoices без реквизитов → 422', function () {
Storage::fake('local');
$tenant = Tenant::factory()->create();
makeSellerLe();
$this->actingAs(User::factory()->create(['tenant_id' => $tenant->id]));
$this->postJson('/api/billing/invoices', ['amount_rub' => 2000])->assertStatus(422);
});
it('GET /api/billing/invoices/{id}/pdf скачивает PDF своего счёта', function () {
Storage::fake('local');
$tenant = Tenant::factory()->create();
makeSellerLe();
makeClientRequisites($tenant->id);
$this->actingAs(User::factory()->create(['tenant_id' => $tenant->id]));
$invoice = app(InvoiceService::class)->create($tenant->id, '2000.00', null);
$this->get("/api/billing/invoices/{$invoice->id}/pdf")
->assertOk()
->assertHeader('content-type', 'application/pdf');
});
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use App\Mail\InvoicePaidNotification;
use App\Models\LegalEntity;
use App\Models\SaasInvoice;
use App\Models\SaasTransaction;
use App\Models\SaasUpdDocument;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Billing\Invoice\InvoicePaymentService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
uses(DatabaseTransactions::class);
function seedPaidScenario(string $balance, string $amount): array
{
$tenant = Tenant::factory()->create(['balance_rub' => $balance]);
User::factory()->create(['tenant_id' => $tenant->id]);
$le = LegalEntity::create([
'code' => 'mp_'.uniqid(), 'name' => 'ИП Лидерра', 'legal_form' => 'IP',
'inn' => '770000000099', 'is_default' => true,
]);
$invoice = SaasInvoice::create([
'tenant_id' => $tenant->id, 'legal_entity_id' => $le->id,
'invoice_number' => 'СЧ-2026-00777', 'payer_type' => 'legal', 'payer_name' => 'ООО К',
'payer_inn' => '5000000000', 'amount_net' => $amount, 'amount_total' => $amount,
'status' => SaasInvoice::STATUS_ISSUED, 'issued_at' => now(), 'expires_at' => now()->addDays(5),
]);
return [$tenant, $invoice];
}
it('mark-paid зачисляет баланс, ставит paid, создаёт акт и шлёт письмо', function () {
Storage::fake('local');
Mail::fake();
[$tenant, $invoice] = seedPaidScenario('100.00', '1500.00');
app(InvoicePaymentService::class)->markPaid($invoice->id);
$invoice->refresh();
$tenant->refresh();
expect($invoice->status)->toBe(SaasInvoice::STATUS_PAID)
->and($invoice->paid_at)->not->toBeNull()
->and((string) $tenant->balance_rub)->toBe('1600.00')
->and(SaasTransaction::where('invoice_id', $invoice->id)->where('status', 'success')->count())->toBe(1)
->and(SaasUpdDocument::where('invoice_id', $invoice->id)->count())->toBe(1);
$actPath = SaasUpdDocument::where('invoice_id', $invoice->id)->value('pdf_path');
Mail::assertQueued(InvoicePaidNotification::class, fn ($mail) => $mail->actPdfPath === $actPath
&& count($mail->attachments()) === 1);
});
it('повторный mark-paid идемпотентен — баланс не удваивается, второй акт не создаётся', function () {
Storage::fake('local');
Mail::fake();
[$tenant, $invoice] = seedPaidScenario('0.00', '500.00');
$svc = app(InvoicePaymentService::class);
$svc->markPaid($invoice->id);
$svc->markPaid($invoice->id);
$tenant->refresh();
expect((string) $tenant->balance_rub)->toBe('500.00')
->and(SaasUpdDocument::where('invoice_id', $invoice->id)->count())->toBe(1);
});
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
use App\Models\LegalEntity;
use App\Models\SaasInvoice;
use App\Models\Tenant;
use App\Services\Billing\Invoice\InvoiceNumberGenerator;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Carbon;
uses(DatabaseTransactions::class);
function makeLeForNumbering(): LegalEntity
{
return LegalEntity::create([
'code' => 'le_num_'.uniqid(), 'name' => 'ИП Тест', 'legal_form' => 'IP', 'inn' => '770000000000',
]);
}
function seedNumberingInvoice(int $tenantId, int $legalEntityId, string $number, string $issuedAt): SaasInvoice
{
return SaasInvoice::create([
'tenant_id' => $tenantId,
'legal_entity_id' => $legalEntityId,
'invoice_number' => $number,
'payer_type' => 'legal',
'amount_net' => '100.00',
'amount_total' => '100.00',
'status' => SaasInvoice::STATUS_ISSUED,
'issued_at' => $issuedAt,
'expires_at' => $issuedAt,
]);
}
it('первый счёт юрлица за год получает номер -00001', function () {
$le = makeLeForNumbering();
$num = (new InvoiceNumberGenerator)->next($le->id, Carbon::parse('2026-06-29 12:00:00'));
expect($num)->toBe('СЧ-2026-00001');
});
it('следующий номер инкрементируется по существующим счетам того же юрлица/года', function () {
$tenant = Tenant::factory()->create();
$le = makeLeForNumbering();
seedNumberingInvoice($tenant->id, $le->id, 'СЧ-2026-00007', '2026-03-01 00:00:00');
$num = (new InvoiceNumberGenerator)->next($le->id, Carbon::parse('2026-06-29 12:00:00'));
expect($num)->toBe('СЧ-2026-00008');
});
it('нумерация изолирована по юрлицу', function () {
$tenant = Tenant::factory()->create();
$leA = makeLeForNumbering();
$leB = makeLeForNumbering();
seedNumberingInvoice($tenant->id, $leA->id, 'СЧ-2026-00042', '2026-02-01 00:00:00');
expect((new InvoiceNumberGenerator)->next($leB->id, Carbon::parse('2026-06-29 12:00:00')))
->toBe('СЧ-2026-00001');
});
@@ -0,0 +1,64 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import AdminInvoicesView from '../../resources/js/views/admin/AdminInvoicesView.vue';
import * as adminApi from '../../resources/js/api/admin';
const oneIssued = {
data: [
{
id: 5,
invoice_number: 'СЧ-2026-00005',
amount_total: '700.00',
status: 'issued',
issued_at: '2026-06-29',
expires_at: null,
tenant_id: 2,
tenant_name: 'ООО Клиент',
payer_name: 'ООО Клиент',
},
],
meta: { total: 1, current_page: 1, last_page: 1, per_page: 25 },
};
describe('AdminInvoicesView', () => {
beforeEach(() => vi.clearAllMocks());
it('рендерит счёт и его статус', async () => {
vi.spyOn(adminApi, 'listAdminInvoices').mockResolvedValue(oneIssued);
const w = mount(AdminInvoicesView, { global: { plugins: [createVuetify()] } });
await flushPromises();
expect(w.text()).toContain('СЧ-2026-00005');
expect(w.text()).toContain('Выставлен');
});
it('«Отметить оплаченным» открывает диалог и зовёт markInvoicePaid после подтверждения', async () => {
vi.spyOn(adminApi, 'listAdminInvoices').mockResolvedValue(oneIssued);
const spy = vi.spyOn(adminApi, 'markInvoicePaid').mockResolvedValue();
const w = mount(AdminInvoicesView, {
global: {
plugins: [createVuetify()],
stubs: {
VDialog: {
template: '<div class="dialog-stub" v-if="modelValue"><slot /></div>',
props: ['modelValue'],
},
},
},
});
await flushPromises();
const btn = w.find('[data-testid="mark-paid-5"]');
expect(btn.exists()).toBe(true);
await btn.trigger('click');
await w.vm.$nextTick();
const confirm = w.findAll('button').find((b) => b.text().includes('Подтверждаю'));
expect(confirm).toBeTruthy();
await confirm!.trigger('click');
await flushPromises();
expect(spy).toHaveBeenCalledWith(5);
});
});
@@ -51,8 +51,9 @@ describe('AdminPricingTiersView', () => {
it('renders 7 tier rows from /api/admin/pricing-tiers', async () => {
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
expect(wrapper.text()).toContain('500.00');
expect(wrapper.text()).toContain('250.00');
// fmtRub форматирует как «500 ₽» (целое, без хвостовых нулей), не «500.00».
expect(wrapper.text()).toContain('500 ₽');
expect(wrapper.text()).toContain('250 ₽');
});
it('shows "все свыше" for tier 7 with leads_in_tier=null', async () => {
@@ -44,18 +44,36 @@ describe('AdminSupplierIntegrationView — manual queue section', () => {
expect(text).toContain('B1');
});
it('clicking «Отметить выполнено» calls resolve endpoint', async () => {
it('clicking «Отметить выполнено» → подтверждение в диалоге → calls resolve endpoint', async () => {
(axios.post as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { resolved: true, external_id: 700123 },
});
vi.spyOn(window, 'confirm').mockReturnValue(true);
const wrapper = mount(AdminSupplierIntegrationView, { global: { plugins: [vuetify] } });
// window.confirm заменён на v-dialog (UI-аудит). Стабим VDialog passthrough,
// чтобы контент диалога рендерился инлайн и кнопка «Подтверждаю» была кликабельна.
const wrapper = mount(AdminSupplierIntegrationView, {
global: {
plugins: [vuetify],
stubs: {
VDialog: {
template: '<div class="dialog-stub" v-if="modelValue"><slot /></div>',
props: ['modelValue'],
},
},
},
});
await new Promise((r) => setTimeout(r, 50));
// Клик по «Отметить выполнено» открывает диалог подтверждения (askResolve).
const btn = wrapper.find('[data-testid="resolve-1"]');
expect(btn.exists()).toBe(true);
await btn.trigger('click');
await wrapper.vm.$nextTick();
// Подтверждаем в диалоге → doResolve → POST.
const confirmBtn = wrapper.findAll('button').find((b) => b.text().includes('Подтверждаю'));
expect(confirmBtn).toBeTruthy();
await confirmBtn!.trigger('click');
expect(axios.post).toHaveBeenCalledWith(expect.stringContaining('/manual-queue/1/resolve'));
});
@@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import InvoicesTable from '../../resources/js/components/billing/InvoicesTable.vue';
import * as billingApi from '../../resources/js/api/billing';
import type { BillingInvoice } from '../../resources/js/api/billing';
function inv(over: Partial<BillingInvoice> = {}): BillingInvoice {
return {
id: 1,
invoice_number: 'СЧ-2026-00001',
amount_total: '1500.00',
status: 'issued',
issued_at: '2026-06-29T09:00:00+00:00',
expires_at: '2026-07-05T09:00:00+00:00',
has_pdf: true,
has_act: false,
pdf_url: '/api/billing/invoices/1/pdf',
act_url: null,
...over,
};
}
describe('InvoicesTable — список счетов', () => {
beforeEach(() => vi.clearAllMocks());
it('рендерит счёт со статусом и кнопкой «Счёт»; кнопки «Акт» нет пока счёт не оплачен', async () => {
vi.spyOn(billingApi, 'getInvoices').mockResolvedValue({ data: [inv()] });
const w = mount(InvoicesTable, { global: { plugins: [createVuetify()] } });
await flushPromises();
expect(w.text()).toContain('СЧ-2026-00001');
expect(w.text()).toContain('Выставлен');
expect(w.find('[data-testid="inv-pdf-1"]').exists()).toBe(true);
expect(w.find('[data-testid="inv-act-1"]').exists()).toBe(false);
});
it('для оплаченного счёта показывает кнопку «Акт»', async () => {
vi.spyOn(billingApi, 'getInvoices').mockResolvedValue({
data: [inv({ id: 2, status: 'paid', has_act: true, act_url: '/api/billing/invoices/2/act' })],
});
const w = mount(InvoicesTable, { global: { plugins: [createVuetify()] } });
await flushPromises();
expect(w.text()).toContain('Оплачен');
expect(w.find('[data-testid="inv-act-2"]').exists()).toBe(true);
});
it('пустой список — empty-state', async () => {
vi.spyOn(billingApi, 'getInvoices').mockResolvedValue({ data: [] });
const w = mount(InvoicesTable, { global: { plugins: [createVuetify()] } });
await flushPromises();
expect(w.text()).toContain('появятся');
});
});
@@ -15,9 +15,10 @@ describe('ChangePasswordCard (Q.DEFER.003 sub-B)', () => {
});
it('shows last-change hint text', () => {
// Дата берётся из GET /api/account/security; без backend (в тесте) — честное
// «не менялся» (хардкод-демо «12.04.2026» убран намеренно, не показываем фейк).
const wrapper = factory();
expect(wrapper.text()).toContain('Последняя смена: 12.04.2026');
expect(wrapper.text()).toContain('26 дней назад');
expect(wrapper.text()).toContain('Последняя смена: не менялся');
});
it('renders «Сменить пароль» button with lock-reset icon', () => {
@@ -88,8 +88,8 @@ describe('DealDetailBody ↔ GET /api/deals/{id} integration', () => {
expect(dealsApi.getDeal).toHaveBeenCalledWith(MOCK_DEALS[0].id, 1);
const items = wrapper.findAll('.timeline-item');
expect(items).toHaveLength(2);
// status_changed event имеет detail "new → won".
expect(wrapper.text()).toContain('new → won');
// status_changed мапит слаги в русские ярлыки воронки: new→«Новая сделка», won→«Сделка».
expect(wrapper.text()).toContain('Новая сделка → Сделка');
});
it('getDeal reject → eventsFetchError=true, alert виден, events пуст (I3)', async () => {
+6 -2
View File
@@ -57,8 +57,12 @@ describe('DealsView.vue — реестр лидов', () => {
it('панель экспорта: поля дат + кнопки Excel/CSV', async () => {
const w = await mountDeals();
expect(w.find('[data-testid="export-from"]').exists()).toBe(true);
expect(w.find('[data-testid="export-to"]').exists()).toBe(true);
const panel = w.find('.export-panel');
expect(panel.exists()).toBe(true);
// 2 поля даты: RuDateField построен на v-menu, поэтому data-testid не доходит
// до DOM-элемента — проверяем по input'ам активаторов (v-text-field) внутри панели.
expect(panel.findAll('input').length).toBeGreaterThanOrEqual(2);
// Кнопки — v-btn, data-testid доходит до корня button.
expect(w.find('[data-testid="export-xlsx-btn"]').exists()).toBe(true);
expect(w.find('[data-testid="export-csv-btn"]').exists()).toBe(true);
});
+7 -9
View File
@@ -38,25 +38,23 @@ describe('ErrorView.vue', () => {
expect(text).toContain('Все рабочие экраны Лидерра доступны через дашборд');
});
it('errorCode=403 показывает «403 / У вас нет доступа» + RequestId', async () => {
it('errorCode=403 показывает «403 / У вас нет доступа» (фейк-RequestId убран)', async () => {
const wrapper = await mountErrorView('403');
const text = wrapper.text();
expect(wrapper.find('.err-code').text()).toBe('403');
expect(text).toContain('У вас нет доступа');
expect(text).toContain('REQ-3F8A2-0007');
expect(text).toContain('Запрос');
// Хардкод «REQ-3F8A2-0007» убран намеренно (не показываем фейк как настоящее).
expect(text).not.toContain('REQ-3F8A2-0007');
});
it('errorCode=500 показывает «500 / Что-то пошло не так» + IncidentId + status-list', async () => {
it('errorCode=500 показывает «500 / Что-то пошло не так» (фейк-Incident/status-list убраны)', async () => {
const wrapper = await mountErrorView('500');
const text = wrapper.text();
expect(wrapper.find('.err-code').text()).toBe('500');
expect(text).toContain('Что-то пошло не так');
expect(text).toContain('INC-2026-0507-0034');
expect(text).toContain('Инцидент');
// status-list только на 500.
expect(text).toContain('API · OK');
expect(text).toContain('Telegram · деградация');
// Хардкод «INC-2026-0507-0034» + фейк-список статусов убраны намеренно.
expect(text).not.toContain('INC-2026-0507-0034');
expect(text).not.toContain('Telegram · деградация');
});
it('404 содержит «На дашборд» primary + «Назад» secondary', async () => {
+34 -37
View File
@@ -9,24 +9,32 @@ vi.mock('../../resources/js/api/billing');
const vuetify = createVuetify();
function inv(over: Partial<BillingInvoice> = {}): BillingInvoice {
return {
id: 1,
invoice_number: 'СЧ-2026-00001',
amount_total: '990.00',
status: 'issued',
issued_at: '2026-05-07T00:00:00Z',
expires_at: '2026-05-14T00:00:00Z',
has_pdf: true,
has_act: false,
pdf_url: '/api/billing/invoices/1/pdf',
act_url: null,
...over,
};
}
describe('InvoicesTable.vue', () => {
it('показывает empty-state без счетов', async () => {
vi.mocked(billingApi.getInvoices).mockResolvedValue({ data: [] });
const wrapper = mount(InvoicesTable, { global: { plugins: [vuetify] } });
await flushPromises();
expect(wrapper.text()).toContain('Счета появятся');
expect(wrapper.text()).toContain('появятся');
});
it('рендерит строки счетов из API', async () => {
const inv: BillingInvoice = {
id: 1,
invoice_number: 'СЧ-2026-00001',
amount_total: '990.00',
status: 'issued',
issued_at: '2026-05-07T00:00:00Z',
has_pdf: true,
};
vi.mocked(billingApi.getInvoices).mockResolvedValue({ data: [inv] });
vi.mocked(billingApi.getInvoices).mockResolvedValue({ data: [inv()] });
const wrapper = mount(InvoicesTable, { global: { plugins: [vuetify] } });
await flushPromises();
const text = wrapper.text();
@@ -34,36 +42,31 @@ describe('InvoicesTable.vue', () => {
expect(text).toContain('Выставлен');
});
it('PDF-кнопка disabled при has_pdf=false и активна при has_pdf=true', async () => {
it('кнопка «Счёт» disabled при has_pdf=false и активна при has_pdf=true', async () => {
const invs: BillingInvoice[] = [
{
id: 1,
invoice_number: 'СЧ-2026-00010',
amount_total: '990.00',
status: 'issued',
issued_at: '2026-05-07T00:00:00Z',
has_pdf: false,
},
{
id: 2,
invoice_number: 'СЧ-2026-00011',
amount_total: '500.00',
status: 'paid',
issued_at: '2026-05-08T00:00:00Z',
has_pdf: true,
},
inv({ id: 1, invoice_number: 'СЧ-2026-00010', has_pdf: false, pdf_url: null }),
inv({ id: 2, invoice_number: 'СЧ-2026-00011', status: 'paid', has_pdf: true }),
];
vi.mocked(billingApi.getInvoices).mockResolvedValue({ data: invs });
const wrapper = mount(InvoicesTable, { global: { plugins: [vuetify] } });
await flushPromises();
const pdfButtons = wrapper.findAll('button').filter((b) => b.text().includes('PDF'));
const pdfButtons = wrapper.findAll('button, a').filter((b) => b.text().includes('Счёт'));
expect(pdfButtons).toHaveLength(2);
// Строка 1 (has_pdf=false) → disabled; строка 2 (has_pdf=true) → активна.
expect(pdfButtons[0].attributes('disabled')).toBeDefined();
expect(pdfButtons[1].attributes('disabled')).toBeUndefined();
});
it('показывает кнопку «Акт» только при has_act=true', async () => {
vi.mocked(billingApi.getInvoices).mockResolvedValue({
data: [inv({ id: 7, status: 'paid', has_act: true, act_url: '/api/billing/invoices/7/act' })],
});
const wrapper = mount(InvoicesTable, { global: { plugins: [vuetify] } });
await flushPromises();
expect(wrapper.find('[data-testid="inv-act-7"]').exists()).toBe(true);
});
it('показывает error-alert при сбое', async () => {
vi.mocked(billingApi.getInvoices).mockRejectedValue(new Error('fail'));
const wrapper = mount(InvoicesTable, { global: { plugins: [vuetify] } });
@@ -72,15 +75,9 @@ describe('InvoicesTable.vue', () => {
});
it('renders amount_total with ₽ suffix', async () => {
const inv: BillingInvoice = {
id: 1,
invoice_number: 'INV-1',
amount_total: '1234.00',
status: 'paid',
issued_at: '2026-05-23T00:00:00Z',
has_pdf: true,
};
vi.mocked(billingApi.getInvoices).mockResolvedValue({ data: [inv] });
vi.mocked(billingApi.getInvoices).mockResolvedValue({
data: [inv({ invoice_number: 'INV-1', amount_total: '1234.00', status: 'paid' })],
});
const wrapper = mount(InvoicesTable, { global: { plugins: [vuetify] } });
await flushPromises();
expect(wrapper.text()).toMatch(/1\s?234\s?₽/);
+8 -1
View File
@@ -12,7 +12,9 @@ describe('KanbanCard.vue', () => {
});
it('рендерит имя, телефон, проект и стоимость', () => {
const wrapper = factory(MOCK_DEALS[0]);
// Карточка показывает ФАКТ списания (costKopecks), а не legacy cost; при отсутствии
// списания — «—». Формат проверяем на сделке с costKopecks=185000 → «1 850 ₽».
const wrapper = factory({ ...MOCK_DEALS[0], costKopecks: 185000 });
const text = wrapper.text();
expect(text).toContain('Анна Соколова');
expect(text).toContain('+7 (916) 871-23-45');
@@ -20,6 +22,11 @@ describe('KanbanCard.vue', () => {
expect(text).toMatch(/1\s+850\s*₽/);
});
it('показывает «—» в стоимости когда списания ещё не было (costKopecks=null)', () => {
const wrapper = factory({ ...MOCK_DEALS[0], costKopecks: null });
expect(wrapper.find('.card-cost').text()).toBe('—');
});
it('показывает initials менеджера', () => {
const wrapper = factory(MOCK_DEALS[0]);
expect(wrapper.text()).toContain('ИП');
+14 -10
View File
@@ -24,9 +24,9 @@ const mountAt = async (path: string) => {
};
describe('LegalDocView.vue', () => {
it('рендерит «Договор-оферта» на /legal/offer', async () => {
it('рендерит «Публичная оферта» на /legal/offer', async () => {
const wrapper = await mountAt('/legal/offer');
expect(wrapper.text()).toContain('Договор-оферта');
expect(wrapper.text()).toContain('Публичная оферта');
});
it('рендерит «Политика конфиденциальности» на /legal/privacy', async () => {
@@ -34,17 +34,21 @@ describe('LegalDocView.vue', () => {
expect(wrapper.text()).toContain('Политика конфиденциальности');
});
it('показывает честную заглушку «документ готовится», а не фейк-текст', async () => {
it('показывает реальный текст оферты (рабочая редакция под ЮKassa), а не заглушку', async () => {
const wrapper = await mountAt('/legal/offer');
const notice = wrapper.find('[data-testid="legal-stub-notice"]');
expect(notice.exists()).toBe(true);
expect(notice.text()).toContain('готовится');
const text = wrapper.text();
// Реальные разделы + дата редакции из content/legalDocs.ts.
expect(text).toContain('Предмет');
expect(text).toContain('Реквизиты Исполнителя');
expect(text).toContain('Редакция от 2026-06-24');
expect(text).not.toContain('готовится');
});
it('содержит ссылку возврата ко входу', async () => {
it('политика конфиденциальности содержит реальные разделы (оператор/права субъекта)', async () => {
const wrapper = await mountAt('/legal/privacy');
const back = wrapper.find('a.legal-back');
expect(back.exists()).toBe(true);
expect(back.attributes('href')).toBe('/login');
const text = wrapper.text();
expect(text).toContain('Оператор');
expect(text).toContain('Права субъекта');
expect(text).toContain('Редакция от 2026-06-24');
});
});
+8 -5
View File
@@ -104,7 +104,7 @@ describe('ProjectsView', () => {
cards[0].vm.$emit('toggle-select', 1);
cards[1].vm.$emit('toggle-select', 2);
await wrapper.vm.$nextTick();
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).toBe(true);
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).isVisible()).toBe(true);
});
});
@@ -155,7 +155,8 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => {
expect(drawer.exists()).toBe(true);
expect(drawer.classes()).not.toContain('open');
// BulkActionsBar uses v-if; should not exist.
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).toBe(false);
// BulkActionsBar теперь v-show (всегда смонтирован, скрыт display:none) → проверяем видимость.
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).isVisible()).toBe(false);
// .has-drawer class should not be on the view root.
expect(wrapper.find('.projects-view').classes()).not.toContain('has-drawer');
});
@@ -173,7 +174,8 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => {
expect(drawer.exists()).toBe(true);
expect(drawer.classes()).toContain('open');
// BulkActionsBar should NOT exist (size < 2).
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).toBe(false);
// BulkActionsBar теперь v-show (всегда смонтирован, скрыт display:none) → проверяем видимость.
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).isVisible()).toBe(false);
// .has-drawer class should be present.
expect(wrapper.find('.projects-view').classes()).toContain('has-drawer');
});
@@ -192,7 +194,7 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => {
expect(drawer.exists()).toBe(true);
expect(drawer.classes()).not.toContain('open');
// BulkActionsBar should exist (size >= 2).
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).toBe(true);
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).isVisible()).toBe(true);
// .has-drawer class should NOT be present.
expect(wrapper.find('.projects-view').classes()).not.toContain('has-drawer');
});
@@ -215,7 +217,8 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => {
// Both should be hidden now.
const drawerAfter = wrapper.find('aside.project-details-drawer');
expect(drawerAfter.classes()).not.toContain('open');
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).toBe(false);
// BulkActionsBar теперь v-show (всегда смонтирован, скрыт display:none) → проверяем видимость.
expect(wrapper.findComponent({ name: 'BulkActionsBar' }).isVisible()).toBe(false);
expect(wrapper.find('.projects-view').classes()).not.toContain('has-drawer');
});
+13 -5
View File
@@ -2,12 +2,20 @@ import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
import SettingsView from '../../resources/js/views/SettingsView.vue';
// SettingsView читает route.query.tab (deep-link вкладки) через useRoute(),
// поэтому компоненту нужен router-контекст в тестах.
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/settings', name: 'settings', component: SettingsView }],
});
describe('SettingsView.vue', () => {
const factory = () =>
mount(SettingsView, {
global: { plugins: [createPinia(), createVuetify()] },
global: { plugins: [createPinia(), createVuetify(), router] },
});
it('монтируется и содержит заголовок «Настройки»', () => {
@@ -15,10 +23,10 @@ describe('SettingsView.vue', () => {
expect(wrapper.find('h1').text()).toBe('Настройки');
});
it('содержит ровно 4 nav-tabs (placeholder-вкладки убраны, audit D6/D7)', () => {
it('содержит ровно 5 nav-tabs (Профиль/Реквизиты/Безопасность/API/Уведомления)', () => {
const wrapper = factory();
const items = wrapper.findAll('.tabs-rail .v-list-item');
expect(items.length).toBe(4);
expect(items.length).toBe(5);
});
it('содержит все 4 названия рабочих вкладок', () => {
@@ -52,8 +60,8 @@ describe('SettingsView.vue', () => {
await wrapper.vm.$nextTick();
const text = wrapper.text();
expect(text).toContain('События × каналы');
// 8 типов событий из schema users.notification_preferences.
['Новый лид', 'Напоминание', 'Низкий баланс', 'Нулевой баланс', 'Анонсы и промо'].forEach((e) =>
// Типы событий из schema users.notification_preferences (актуальный набор).
['Новый лид', 'Низкий баланс', 'Нулевой баланс', 'Пополнение успешно', 'Анонсы и промо'].forEach((e) =>
expect(text).toContain(e),
);
});
@@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import TopupDialog from '../../resources/js/components/billing/TopupDialog.vue';
import * as billingApi from '../../resources/js/api/billing';
describe('TopupDialog — оплата по счёту', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('open', vi.fn());
});
it('при способе «По счёту» и сумме вызывает createInvoice и открывает PDF', async () => {
const spy = vi.spyOn(billingApi, 'createInvoice').mockResolvedValue({
id: 1,
invoice_number: 'СЧ-2026-00001',
amount_total: '1500.00',
pdf_url: '/api/billing/invoices/1/pdf',
});
const wrapper = mount(TopupDialog, {
props: { modelValue: true },
global: { plugins: [createVuetify()] },
});
const vm = wrapper.vm as unknown as { method: string; amount: number | null; submit: () => Promise<void> };
vm.method = 'invoice';
vm.amount = 1500;
await vm.submit();
await flushPromises();
expect(spy).toHaveBeenCalledWith(1500);
expect(window.open).toHaveBeenCalledWith('/api/billing/invoices/1/pdf', '_blank');
expect(wrapper.emitted('invoiced')?.[0]).toEqual(['СЧ-2026-00001']);
});
it('способ «Картой» вызывает topup, не createInvoice', async () => {
const topupSpy = vi.spyOn(billingApi, 'topup').mockResolvedValue({ balance_rub: '2000.00' });
const invoiceSpy = vi.spyOn(billingApi, 'createInvoice');
const wrapper = mount(TopupDialog, {
props: { modelValue: true },
global: { plugins: [createVuetify()] },
});
const vm = wrapper.vm as unknown as { method: string; amount: number | null; submit: () => Promise<void> };
vm.method = 'card';
vm.amount = 2000;
await vm.submit();
await flushPromises();
expect(topupSpy).toHaveBeenCalledWith(2000);
expect(invoiceSpy).not.toHaveBeenCalled();
});
});
+6 -14
View File
@@ -1,6 +1,6 @@
# Brain Status (auto-generated)
Last updated: 2026-06-28T08:09:57.221Z
Last updated: 2026-06-29T08:26:07.630Z
| Контролёр | Состояние | Детали |
|---|---|---|
@@ -33,21 +33,13 @@ Last updated: 2026-06-28T08:09:57.221Z
| enforce-coverage-verify.mjs | `enforce-coverage-verify.mjs` | 🔴 |
| enforce-todowrite-skill-verifier.mjs | `enforce-todowrite-skill-verifier.mjs` | 🔴 |
Недавние escape владельца: 0 · Недавние блоки: 3
**Недавние блоки (детали):**
| Время | Действие | Причина |
|---|---|---|
| 2026-06-27T11:50:42.480Z | bash:cd "c:/моя/проекты/claude-brain" && git add -- "docs/superpowers/specs/2026-06-27-secretary-closing-doors-design.md | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:cd "c:/моя/проекты/cla |
| 2026-06-27T10:01:08.010Z | bash:git restore --staged docs/observer/STATUS.md 2>/dev/null; git diff --staged --name-only | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:git restore --staged d |
| 2026-06-27T09:25:54.127Z | bash:node -e "for (const d of ['протокол-наставника','проблема-закрытия-вопросов-протокола','содержит']) { try { const p | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:node -e "for (const d |
Недавние escape владельца: 0 · Недавние блоки: 0
## Метрики (информационные, не алерты)
- Observer evidence: 2354 episodes this month, 0 observer_error markers, 8 PII matches before filter
- Legacy v1 episodes (not in factor analysis): 2354
- Last /brain-retro: 32 day(s) ago
- Last /brain-retro: 33 day(s) ago
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 0. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
## Метрики дисциплины
@@ -125,9 +117,9 @@ Episodes since last run: 542 / threshold: 10
| PID | Имя | CPU-время | Возраст |
|---|---|---|---|
| 3440 | MsMpEng | 17.61ч | NaNч |
| 21928 | Code | 7.87ч | 0.0ч |
| 1212 | svchost | 4.49ч | NaNч |
| 3440 | MsMpEng | 20.00ч | NaNч |
| 21928 | Code | 10.83ч | NaNч |
| 1212 | svchost | 5.68ч | NaNч |
⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,164 @@
# Оплата по счёту (банковский перевод) — дизайн
**Дата:** 2026-06-29
**Статус:** утверждён владельцем (устно: «ок делай»), Этап 1
**Автор сессии:** Claude (brainstorming)
---
## 0. Контекст и решение по каналу
Владелец хочет добавить **оплату по счёту** для клиентов-юрлиц/ИП в дополнение к онлайн-оплате картой (ЮKassa, уже работает).
**Исследование зафиксировало (важно, не переоткрывать):**
- **ЮKassa для этого сценария не подходит.** Её «оплата по счёту» (B2B) работает только через **Сбербанк Бизнес Онлайн** — обе стороны должны быть в Сбербанке. Расчётный счёт ИП открыт в **ВТБ** → путь закрыт. Обычная платёжка ВТБ↔другой банк идёт мимо ЮKassa.
- **Идея «деньги паркуются на счёте ЮKassa, потом раздаём» — нереализуема** для банковского перевода от юрлица. Деньги всегда падают на наш расчётный счёт (ВТБ).
- **Автомат возможен только со стороны ВТБ** через «Интеграционный Банк-Клиент» (ВТБ API, REST h2h). Но: **только poll, без вебхуков**; частота обновления выписки публично не задокументирована (риск «раз в сутки»); технически сложно (КриптоПро, сертификат УНЭП). → **Это Этап 2**, после подтверждения у банка.
**Решение владельца:** идём **поэтапно**.
- **Этап 1 (эта спека):** выставление счёта (самообслуживание клиентом) + закрывающий документ + ручная отметка оплаты администратором → автозачисление баланса.
- **Этап 2 (отдельная спека позже):** заменить ручную отметку на автоматический опрос ВТБ API. Перед началом — список вопросов менеджеру ВТБ (частота выписки, лимит опроса, условия подключения ИБК, СБП для бизнеса).
---
## 1. Цель Этапа 1
Клиент-юрлицо самостоятельно формирует счёт на пополнение баланса, оплачивает его банковским переводом по нашим реквизитам ВТБ. Администратор, увидев поступление, одной кнопкой подтверждает оплату — система автоматически зачисляет баланс, формирует закрывающий документ (Акт, без НДС) и уведомляет клиента.
**Критерии успеха:**
1. Клиент может ввести/подтянуть реквизиты компании, сумму и скачать PDF-счёт.
2. Счёт виден в «Моих счетах» со статусом.
3. Администратор видит выставленные счета и отмечает оплату одной кнопкой (с переспросом).
4. Отметка оплаты атомарно: зачисляет баланс (тот же ledger, что онлайн), формирует Акт PDF, шлёт письмо, ставит статус «оплачен». Повтор — no-op.
5. Клиент скачивает счёт и акт из кабинета.
6. Все суммы и документы — «Без НДС» (УСН).
7. **Схема БД не меняется** — переиспользуем существующие таблицы.
**Вне scope Этапа 1 (YAGNI):** автоматический опрос ВТБ; загрузка банковской выписки и авто-матч; УПД со счётом-фактурой (НДС); частичные оплаты; возвраты по счетам.
---
## 2. Что уже есть в коде (переиспользуем, НЕ строим заново)
| Готово | Где | Как используем |
|---|---|---|
| Таблица счетов `saas_invoices` (номер, плательщик ЮЛ/физлицо, ИНН/КПП/адрес, суммы, НДС, статусы `draft/issued/paid/overdue/cancelled`, `expires_at`, `pdf_path`, `transaction_id`) | `db/schema.sql:2371` | хранение счёта |
| Позиции счёта `saas_invoice_items` | `db/schema.sql:2410` | 1 позиция «Пополнение баланса» |
| Таблица закрывающих документов `saas_upd_documents` (покупатель, суммы, `pdf_path`, `status`, `invoice_id`, `transaction_id`, `upd_function` СЧФ/ДОП) | `db/schema.sql:2428` | хранение Акта (function=ДОП, без счёта-фактуры) |
| Транзакции `saas_transactions` (`type=topup`, `invoice_id`, `upd_id`, `payment_method='bank_transfer'`, `legal_entity_id`, статусы) | `db/schema.sql:2492` | строка пополнения |
| RLS-политики на все 4 таблицы (tenant isolation) | `db/schema.sql:3137+` | изоляция тенантов |
| Зачисление баланса `BillingTopupService::topup()` (lockForUpdate + append-only ledger) | `app/Services/Billing/BillingTopupService.php` | автозачисление при отметке оплаты |
| Идемпотентный атомарный claim pending→success + RLS-контекст (`SET LOCAL app.current_tenant_id`) | `app/Http/Controllers/Api/PaymentWebhookController.php` | образец для отметки оплаты |
| Письмо «Счёт оплачен» `InvoicePaidNotification` (шаблон `emails.invoice_paid`) | `app/Mail/InvoicePaidNotification.php` | уведомление клиента |
| Список счетов клиента `GET /api/billing/invoices` | `app/Http/Controllers/Api/BillingController.php:301` | «Мои счета» (расширить) |
| Реквизиты клиента `tenant_requisites` (1:1, ИНН/КПП/ОГРН/адрес/банк) + `RequisitesService::upsert()` | `app/Services/Requisites/RequisitesService.php` | плательщик в счёте |
| Подтяжка по ИНН (DaData) `PartyLookup` / `DaDataPartyClient` | `app/Services/DaData/` | автозаполнение реквизитов |
| Наши юрлица оператора `legal_entities` (ИНН, КПП NULL для ИП, `bank_account`, `bank_bik`, `is_default`) | `db/schema.sql:280` | получатель (наш ИП, ВТБ) |
| Способ онлайн-пополнения (диалог) | `app/resources/js/components/billing/TopupDialog.vue` | добавить вкладку «По счёту» |
---
## 3. Что строим нового
### 3.1. Зависимость: генерация PDF
В проекте **нет PDF-библиотеки**. Добавляем **`barryvdh/laravel-dompdf`** (HTML/Blade → PDF, без бинарников, кириллица через шрифт DejaVu Sans). Альтернативы (mPDF, wkhtmltopdf/snappy) тяжелее или требуют системный бинарь — отвергнуты для простоты на Windows-dev + Linux-prod.
### 3.2. Сервисы (backend)
- **`InvoiceService`** — создание счёта:
- нумерация `СЧ-2026-NNNNN` — последовательная по `legal_entity_id` + год, без дыр, через `UNIQUE (legal_entity_id, invoice_number)` + атомарный инкремент (advisory-lock или `SELECT ... FOR UPDATE` по счётчику);
- заполняет `saas_invoices` (payer из `tenant_requisites`, `legal_entity_id` = `is_default` ИП, `amount_net=amount_total`, `vat_rate=0`, `vat_amount=0`, `payment_purpose` с номером счёта, `expires_at` = +5 рабочих дней) + 1 строку `saas_invoice_items`;
- генерирует PDF счёта → `pdf_path`.
- **`ActService`** (закрывающий документ) — при отметке оплаты:
- создаёт `saas_upd_documents` (`upd_function='ДОП'`, без НДС, buyer из счёта, `invoice_id`, `transaction_id`), генерирует PDF Акта → `pdf_path`.
- **`InvoicePaymentService`** — отметка оплаты (по образцу `PaymentWebhookController`):
- в транзакции с `SET LOCAL app.current_tenant_id`: атомарный claim `saas_invoices.status issued→paid`; если 0 строк — no-op (идемпотентность);
- создаёт `saas_transactions(type=topup, status=success, invoice_id, payment_method='bank_transfer', legal_entity_id)`;
- зачисляет через `BillingTopupService::topup()`; пишет `balance_transaction_id`, `upd_id`;
- вызывает `ActService`; шлёт `InvoicePaidNotification`.
### 3.3. Контроллеры / API
**Клиент (auth + tenant):**
- `POST /api/billing/invoices` — создать счёт `{ amount_rub }` (реквизиты берутся из `tenant_requisites`; если не заполнены — 422 с подсказкой заполнить).
- `GET /api/billing/invoices` — список (расширить существующий: статус, ссылки на PDF счёта и акта).
- `GET /api/billing/invoices/{id}/pdf` — скачать счёт.
- `GET /api/billing/invoices/{id}/act` — скачать акт (если оплачен).
- Реквизиты компании — переиспользовать существующий endpoint G1/SP2 (`RequisitesService`); ИНН-автоподтяжка — существующий DaData endpoint.
**Админ (admin-зона):**
- `GET /api/admin/invoices` — список выставленных/всех счетов (фильтр по статусу, поиск по номеру/клиенту), серверная пагинация (как недавний экран «Тенанты»).
- `POST /api/admin/invoices/{id}/mark-paid` — отметить оплаченным (идемпотентно, через `InvoicePaymentService`).
### 3.4. Экраны (Vue 3 + Vuetify 3, палитра Forest)
- **Клиент:** в `TopupDialog.vue` — вкладка/способ **«По счёту (для юрлиц)»**: форма реквизитов (ИНН→автоподтяжка) при первом разе, сумма, кнопка «Сформировать счёт» → скачивание PDF + тост.
- **Клиент:** раздел **«Мои счета»** (расширить существующий список в BillingView): номер, сумма, статус (Выставлен / Оплачен / Просрочен), кнопки «Скачать счёт» / «Скачать акт».
- **Админ:** экран **«Счета»**: таблица выставленных счетов, серверная пагинация/поиск, кнопка **«Отметить оплаченным»** с диалогом подтверждения (`v-dialog`, как в manual-queue), показ суммы/клиента/номера в подтверждении.
### 3.5. PDF-шаблоны (Blade)
- `resources/views/pdf/invoice.blade.php` — счёт: шапка (наш ИП + ВТБ-реквизиты как получатель), плательщик (реквизиты клиента), таблица позиций, итог, «Без НДС», назначение платежа с номером счёта, срок оплаты.
- `resources/views/pdf/act.blade.php` — Акт об оказании услуг: исполнитель (наш ИП), заказчик (клиент), услуга, сумма, «Без НДС», ссылка на номер счёта.
---
## 4. Поток данных (happy path)
```
Клиент: TopupDialog «По счёту» → (реквизиты, если нужно) → сумма → POST /api/billing/invoices
→ InvoiceService: saas_invoices(issued) + items + PDF → возврат ссылки на PDF
Клиент скачивает счёт, платит платёжкой (в назначении — номер счёта)
... деньги идут на наш счёт ВТБ (~2 часа) ...
Админ: экран «Счета» → «Отметить оплаченным» → подтверждение → POST /api/admin/invoices/{id}/mark-paid
→ InvoicePaymentService (транзакция + SET LOCAL tenant):
claim issued→paid (атомарно; 0 строк = no-op)
saas_transactions(topup, success, bank_transfer)
BillingTopupService::topup() → баланс += сумма (ledger)
ActService → saas_upd_documents(ДОП) + PDF
InvoicePaidNotification (email)
Клиент: видит «Оплачен», скачивает счёт и акт; баланс пополнен
```
## 5. Обработка ошибок и граничные случаи
- **Реквизиты не заполнены** при создании счёта → 422 с понятным сообщением «Заполните реквизиты компании».
- **Сумма** — min/max как у онлайн-пополнения (валидация на бэке).
- **Нумерация** — атомарная, без дыр и гонок (advisory lock per legal_entity); `UNIQUE` ловит дубль.
- **Просроченный счёт**`expires_at` прошёл и не оплачен → статус `overdue` (cron-задача раз в день или ленивый пересчёт при чтении). Просроченный нельзя «оплатить» без админ-переопределения (на Этапе 1 — просто предупреждение в подтверждении).
- **Идемпотентность отметки** — повторное `mark-paid` → claim 0 строк → no-op, без двойного зачисления/двойного акта.
- **RLS** — admin-операция отмечает счёт чужого тенанта: чтение через admin-соединение; зачисление строго под `SET LOCAL app.current_tenant_id` нужного тенанта (как webhook).
- **Деньги**`BillingTopupService::topup()` уже атомарен (lockForUpdate); не дублируем.
## 6. Тестирование (TDD)
- **Unit:** `InvoiceService` (нумерация без дыр, поля счёта, vat=0); `ActService` (ДОП без НДС); генерация PDF не падает (smoke).
- **Feature (backend):** создание счёта клиентом (с/без реквизитов → 422); список; скачивание PDF; `mark-paid` happy-path (баланс += сумма, статус paid, акт создан, письмо отправлено — `Mail::fake`); идемпотентность повторного `mark-paid`; RLS-изоляция (чужой тенант не видит счёт).
- **Frontend (vitest):** вкладка «По счёту» в TopupDialog; «Мои счета» рендерит статусы и кнопки; админ-экран «Счета» зовёт `mark-paid` после подтверждения в диалоге.
- Прод-условие RLS воспроизводить осторожно (тест-БД под суперюзером скрывает RLS-баги — см. [[feedback-prod-full-test-isolated-db]]).
## 7. Предусловия к запуску (данные, не код)
- Заполнить в `legal_entities` строку нашего ИП: ИНН, банк (ВТБ), `bank_account`, `bank_bik`, адрес, `is_default=true`. Без этого шапка счёта пустая. Разовая настройка — собрать у владельца.
## 8. Демо перед выкатом (требование владельца)
Перед любым выкатом на боевой — **локальное демо**: владелец сам формирует счёт, скачивает PDF, отмечает оплату, видит пополнение баланса и акт. Только после «ок» на демо — деплой. (Боевая БД/деплой — отдельно, по эскейпу.)
## 9. Этап 2 (отдельно, не сейчас)
Автоматический опрос ВТБ API («Интеграционный Банк-Клиент»), матч поступления по сумме + назначению (номер счёта) → тот же `InvoicePaymentService`, только триггер не кнопка, а планировщик. Предшествует — вопросы менеджеру ВТБ:
1. Как часто формируется/обновляется выписка, доступная по API? Есть ли SLA?
2. Допустимая частота опроса API (раз в минуту — ок)?
3. Есть ли push/вебхук о входящем платеже (или только опрос)?
4. Условия и стоимость подключения «Интеграционный Банк-Клиент» для ИП; что с сертификатом (КриптоПро/УНЭП)?
5. СБП для бизнеса (B2B): мгновенное зачисление + есть ли API-уведомление?
6. Точные REST-методы получения выписки (из `Specifications_VTB_API`).
Полезные ссылки ВТБ:
- `https://db.vtb.ru/faq/files/vtb-api-kak-rabotaet-servis/Specifications_VTB_API_18.11.24.pdf`
- `https://db.vtb.ru/faq/files/vtb-api-kak-rabotaet-servis/Instruction_VTB_API_21.04.24.pdf`
+4
View File
@@ -10,6 +10,10 @@
> 🟢 **АКТУАЛЬНО (26.06.2026) — перебивает любые упоминания «ждут ООО / Б-1 / осталось подписать» в снимках ниже:** юр. лицо — **ИП, зарегистрировано** (НЕ ООО). **Договор с ЮKassa ПОДПИСАН 26.06** (№НЭК.448000.01), магазин **1392092** активен, приём платежей **включён со стороны ЮKassa**, СБП подключён, `info@liderra.ru` подтверждён. На проде заведены юрлицо+шлюз+webhook (см. снимок go-live ниже). **Флаг `billing_yookassa_enabled` — ВКЛЮЧЁН (подтверждено 27.06 на кластере, намеренно, штатное состояние по решению владельца).** НО **go-live онлайн-оплаты НЕ завершён:** успешной живой оплаты ещё не было (см. свежий снимок 27.06 ниже — 5 тестовых попыток 100₽ отменены на стороне ЮKassa, `paid=false`, деньги не списаны; happy-path «оплата→webhook→зачисление» в бою не проверялся; webhook IP-allowlist пуст). «Регистрация ООО» как блокер — **снято, не актуально.**
**Снимок снят:** 29.06.2026 (~11:00 UTC) — **💳🟢 ВЫКАЧЕНА фича «ОПЛАТА ПО СЧЁТУ» (Этап 1) на боевой liderra.ru.** По командам владельца «ок коммить и пуш, обнови память и деплой только со скилом». **Что выкачено (gitea main `cdfae077`, деплой `bin/deploy-source-edit.sh`):** клиент в кабинете Биллинг → отдельная вкладка **«Счета»** + в «Пополнить» способ «По счёту (для юрлиц)» → формирует **PDF-счёт** по своим реквизитам; админ **`/admin/invoices`** (новый пункт меню «Счета») отмечает оплату одной кнопкой → атомарно зачисляет баланс + формирует **Акт** (без НДС, УСН) + шлёт клиенту письмо «Счёт оплачен» **с вложением PDF-акта**; PDF открываются inline. Просрочка `invoices:expire` (cron 03:40 МСК). Наименование услуги — «**Оплата генерации рекламных лидов**». Зависимость **dompdf** установлена на проде, **«Nothing to migrate»** (схему БД не меняли — таблицы saas_invoices/items/upd уже были). **Реквизиты ИП заполнены в боевом `legal_entities` id=1** (`ip_kondratev`, is_default): банк **Филиал «Центральный» ВТБ (ПАО) Красноярск**, р/с **40802810690810008032**, корр 30101810145250000411, БИК 044525411, ОКПО 2052921109, адрес Красноярск ул.Новосибирская 5-97 (было bank=null). **Предполёт 8/8 GREEN** (prod-deploy-validator), финиш через superpowers:finishing-a-development-branch. **Проверка:** приложение live, HTTPS /login 200, новых ошибок в логе нет, **деньги целы t2 = 1 839 210 ₽ / 1019 сделок**, счетов на проде 0 (чисто, клиенты ещё не выставляли). **ЮKassa для оплаты по счёту НЕ годится** (её B2B только через Сбербанк, обе стороны в Сбере; наш ИП в ВТБ) — деньги по счёту всегда идут прямой платёжкой на р/с ВТБ. **Этап 2** (автозачисление через ВТБ API «Интеграционный Банк-Клиент», только poll, без вебхуков) — отдельно, после вопросов менеджеру ВТБ. Спека/план — `docs/superpowers/specs|plans/2026-06-29-oplata-po-schetu-*`. Память: `project-oplata-po-schetu-etap1`, `reference-ip-requisites-kondratev`.
**Снимок снят:** 28.06.2026 (~13:30 UTC) — **🔍🟢 СКВОЗНАЯ СВЕРКА БАЙТ-В-БАЙТ прод↔git↔бэкап + экран «Тенанты» на 1000 + фронт-стенд в зелёный. Прод приведён к git 1:1.** По командам владельца «сверь прод/локалку/бэкап байт-в-байт» и «да» (выкатить). **Сверка (метод: на Linux `rsync -rclni` checksum, LF без CRLF-шума, свежий клон gitea-main ↔ `/var/www/liderra/app` ↔ распакованный `/tmp/app-backup-0907.tgz`):** исходники прода = **gitea-main `84dfbc85` один-в-один** (контент-расхождений 0 после выката тест-правок); бэкап цел, разница прод↔бэкап = ровно выкат «Тенанты»; **потерянных/изменённых правок НЕТ.** На проде сверх git только не-git артефакты: `*.bak-precutover` ×2 (страховка отката Managed PG — **держать до ~03.07**), `bootstrap/cache/*` ×5 (кэш Laravel, генерится), `tests/Frontend/menuRepositionFix.spec.ts` (старый удалённый тест, висит — деплой без `--delete`; мусор, в рантайме не участвует). **Выкачено в этот заход:** (1) экран «Тенанты» на серверную пагинацию/поиск/фильтры (статус производный CASE + тариф) — демо показано владельцу локально, прод `c92d498b`; (2) фронт-тест-стенд приведён в зелёный — 22 протухших теста в 10 спеках обновлены под актуальные компоненты (только тесты, компоненты не тронуты), `84dfbc85`, выкачены чтобы прод==git. **Полный прогон фронта: 127 файлов / 992 теста зелёные.** **Деньги целы: t2 = 1 839 405 ₽ / 990 сделок** (деплои код-только, БД не тронута). Сайт 200. Рецепт сверки на будущее: rsync-checksum на проде / `git hash-object`, НЕ raw-sha с Windows (CRLF врёт). Кодовая фраза стены — «роутер-наставник».
**Снимок снят:** 28.06.2026 (~11:20 UTC) — **🔴→🟢 НАЙДЕН И ПОЧИНЕН ТИХО СЛОМАННЫЙ БИЛЛИНГ-СТОРОЖ + playwright durable + дашборд балансов/вложенности на проде.** По команде владельца «закрывай хвосты». **Главное — баг биллинга:** после переезда на Managed PG (26.06) очередь `liderra-queue` ходит в БД под ролью `crm_app_user` (RLS), и `BalancePreflightSweepJob`/`BalanceFrozenReminderJob` перебирали тенантов через `Tenant::query()` БЕЗ `app.current_tenant_id` → policy `tenants_self_isolation` отдавала **0 строк** → ночной преflight @18:00 МСК **молча стал no-op с 26.06** (heartbeat зелёный, `balance_freeze_log` пуст, ни заморозок бедных, ни снятия блоков у профинансированных). Симптом — 4 застрявших `preflight_blocked` проекта. **Фикс:** список тенантов теперь через `pgsql_supplier` (BYPASSRLS), модель грузится внутри per-tenant `SET LOCAL`. Тесты Billing 18/18, pint/phpstan чисто. **gitea/main + прод = `75dded78`** (cherry-pick поверх `cab0347f`; выкат `bin/deploy-source-edit.sh`, БД миграциями не тронута). **Прогнал sweep вручную на проде — самоисцеление подтверждено:** t25 (10090₽)→сняты блоки 2 проектов (omega/lkomega), t26 (300₽)→снят блок 1, t27 (тест example.org)→заморожен, t30 (`kaa_555@bk.ru`, 20₽ реальный)→заморожен + письмо «пополните»; `balance_freeze_log` снова пишет. **Деньги целы: t2 = 1 839 405 ₽** (до==после). **Playwright «несносимый»:** объявлен в `app/package.json → dependencies` ровно `1.59.0` (под browser chromium-1217) + lock; деплой получил шаг 6d `npm ci --omit=dev --legacy-peer-deps` (только прод-деп, без тяжёлого фронт-дерева, без скачивания браузера) → переживёт будущие чистки `node_modules` (корень падения синхрона 27.06). Поставлен на прод (3 пакета). **Дашборд (выкачено ранее 28.06):** плитка «Балансы сервисов» + «Пополнить», плитка «Клиенты», экран «Лиды» (серверная пагинация) + карточка лида (цепочка до источника), выбор периода. **🟡 Следующим заходом (решение владельца):** экран «Тенанты» на серверную пагинацию/фильтры для 1000 клиентов. **🔴 YC в минусе −606₽:** пополнить `console.yandex.cloud/billing/accounts/dn2w7fcvynjxe6elljct/payments`. Кодовая фраза стены — «роутер-наставник».
**Снимок снят:** 27.06.2026 (~10:40 UTC) — **🟢 ВЫКАЧЕНО на боевой: (1) снят лишний лимит по ЧИСЛУ проектов, (2) подсказка «Как увеличить количество сделок» в диалоге проекта.** По команде владельца (онлайн-наблюдение за работающим клиентом + правки его затыков). **gitea/main + прод = `fa736136`** (продвинут моими выкатами `c7e015a9``7ac9af7c``fa736136`; ссылка на `c7e015a9` в снимке ~13:00 ниже — стейл/concurrent). **Что сделано:** (1) убран гейт `max_projects` в `ProjectService::create` + поле `active_projects.limit` с дашборда — **правило продукта: лимита по числу проектов НЕТ, только по балансу/заказанным лидам**; у всех тенантов `tenants.limits={}` → код брал дефолт `?? 10` и ложно блокировал tenant 2 (117 проектов) с «Достигнут лимит проектов (10)»; `max_users`/`api_rps` — мёртвые ключи (только в комментариях), колонка `limits` оставлена. (2) подсказка с tooltip в `NewProjectDialog.vue` над «Откуда собирать заявки». **Метод выката:** `bin/deploy-source-edit.sh`**ПОЧИНЕН под кластер** (убраны DB-шаги через `sudo -u postgres` = старая rollback-база на VM; миграции теперь `php artisan migrate --force` в кластер по `.env`, для код-онли = «Nothing to migrate»); скрипт gitignored (внутри пароль gitea). **Verify:** HTTP **200** (главная+login), подсказка в живой сборке `ProjectsView-CSdkdAXR.js`, гейт лимита в боевом коде = 0 совпадений, **деньги целы tenant 2 = 1 839 600 ₽ / 999 731 лид**, миграций нет (БД не тронута). **Бэкап кода на проде** `/tmp/app-backup-2026-06-27-*.tgz`. **🔴 ОТКРЫТЫЙ БАГ (наблюдение по логам 27.06):** сессия клиента **рвётся через ~8–10 мин активной работы** (НЕ таймаут: `SESSION_LIFETIME=120` мин, Redis здоров/noeviction) + интерфейс молчит вместо «войдите заново» (сыпет 401 в фон) → клиент видит пустую страницу. Также путаница паролей у владельца (вводил пароль от `kdv1@bk.ru` на `info@lkomega.ru`; вход — `info@lkomega.ru`/`Liderra2026!`). Разбор: `docs/superpowers/findings/2026-06-27-live-client-observation.md` + `…/2026-06-27-remove-project-count-limit.md`. **Параллельная сессия** работала локально на ветке `feat/admin-command-center` (её коммит `1a92b702` в main НЕ влит — изолировал свои правки worktree+cherry-pick). Кодовая фраза стены — «роутер-наставник».