Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 253d1b7f39 | |||
| 093cc8729b | |||
| cdfae077a3 | |||
| 2281907b8a | |||
| 84dfbc857a |
@@ -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.'"',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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",
|
||||
|
||||
Generated
+523
-144
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
];
|
||||
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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).
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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?₽/);
|
||||
|
||||
@@ -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('ИП');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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`
|
||||
@@ -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). Кодовая фраза стены — «роутер-наставник».
|
||||
|
||||
Reference in New Issue
Block a user