diff --git a/app/app/Console/Commands/ExpireInvoicesCommand.php b/app/app/Console/Commands/ExpireInvoicesCommand.php new file mode 100644 index 00000000..74f5fc65 --- /dev/null +++ b/app/app/Console/Commands/ExpireInvoicesCommand.php @@ -0,0 +1,28 @@ +where('expires_at', '<', now()) + ->update(['status' => SaasInvoice::STATUS_OVERDUE]); + + return self::SUCCESS; + } +} diff --git a/app/app/Http/Controllers/Api/AdminInvoiceController.php b/app/app/Http/Controllers/Api/AdminInvoiceController.php new file mode 100644 index 00000000..f15cace9 --- /dev/null +++ b/app/app/Http/Controllers/Api/AdminInvoiceController.php @@ -0,0 +1,66 @@ +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']); + } +} diff --git a/app/app/Http/Controllers/Api/BillingController.php b/app/app/Http/Controllers/Api/BillingController.php index 10e556b2..5f14e735 100644 --- a/app/app/Http/Controllers/Api/BillingController.php +++ b/app/app/Http/Controllers/Api/BillingController.php @@ -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(), ]); } diff --git a/app/app/Http/Controllers/Api/InvoiceController.php b/app/app/Http/Controllers/Api/InvoiceController.php new file mode 100644 index 00000000..1548131c --- /dev/null +++ b/app/app/Http/Controllers/Api/InvoiceController.php @@ -0,0 +1,85 @@ +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.'"', + ]); + } +} diff --git a/app/app/Mail/InvoicePaidNotification.php b/app/app/Mail/InvoicePaidNotification.php index 7ea1551d..af18adfc 100644 --- a/app/app/Mail/InvoicePaidNotification.php +++ b/app/app/Mail/InvoicePaidNotification.php @@ -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 + */ + 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'), + ]; + } } diff --git a/app/app/Models/LegalEntity.php b/app/app/Models/LegalEntity.php index 4c2cb2fd..840c3cb8 100644 --- a/app/app/Models/LegalEntity.php +++ b/app/app/Models/LegalEntity.php @@ -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', ]; } diff --git a/app/app/Models/SaasInvoice.php b/app/app/Models/SaasInvoice.php new file mode 100644 index 00000000..2a5acfc0 --- /dev/null +++ b/app/app/Models/SaasInvoice.php @@ -0,0 +1,79 @@ + 'decimal:2', + 'vat_amount' => 'decimal:2', + 'amount_total' => 'decimal:2', + 'issued_at' => 'datetime', + 'expires_at' => 'datetime', + 'paid_at' => 'datetime', + 'cancelled_at' => 'datetime', + ]; + } + + /** @return HasMany */ + public function items(): HasMany + { + return $this->hasMany(SaasInvoiceItem::class, 'invoice_id'); + } +} diff --git a/app/app/Models/SaasInvoiceItem.php b/app/app/Models/SaasInvoiceItem.php new file mode 100644 index 00000000..f825a226 --- /dev/null +++ b/app/app/Models/SaasInvoiceItem.php @@ -0,0 +1,42 @@ + 'decimal:3', + 'price' => 'decimal:2', + 'amount_net' => 'decimal:2', + 'amount_total' => 'decimal:2', + ]; + } +} diff --git a/app/app/Models/SaasUpdDocument.php b/app/app/Models/SaasUpdDocument.php new file mode 100644 index 00000000..1378e327 --- /dev/null +++ b/app/app/Models/SaasUpdDocument.php @@ -0,0 +1,60 @@ + 'decimal:2', + 'vat_amount' => 'decimal:2', + 'amount_total' => 'decimal:2', + 'issued_at' => 'datetime', + ]; + } +} diff --git a/app/app/Services/Billing/Invoice/ActService.php b/app/app/Services/Billing/Invoice/ActService.php new file mode 100644 index 00000000..49a1405e --- /dev/null +++ b/app/app/Services/Billing/Invoice/ActService.php @@ -0,0 +1,58 @@ +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; + } +} diff --git a/app/app/Services/Billing/Invoice/InvoiceNumberGenerator.php b/app/app/Services/Billing/Invoice/InvoiceNumberGenerator.php new file mode 100644 index 00000000..e0ce6cf3 --- /dev/null +++ b/app/app/Services/Billing/Invoice/InvoiceNumberGenerator.php @@ -0,0 +1,41 @@ +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); + } +} diff --git a/app/app/Services/Billing/Invoice/InvoicePaymentService.php b/app/app/Services/Billing/Invoice/InvoicePaymentService.php new file mode 100644 index 00000000..8c828f91 --- /dev/null +++ b/app/app/Services/Billing/Invoice/InvoicePaymentService.php @@ -0,0 +1,94 @@ +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, + )); + } + } +} diff --git a/app/app/Services/Billing/Invoice/InvoiceService.php b/app/app/Services/Billing/Invoice/InvoiceService.php new file mode 100644 index 00000000..0f338d77 --- /dev/null +++ b/app/app/Services/Billing/Invoice/InvoiceService.php @@ -0,0 +1,94 @@ +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; + }); + } +} diff --git a/app/app/Services/Billing/Invoice/PdfRenderer.php b/app/app/Services/Billing/Invoice/PdfRenderer.php new file mode 100644 index 00000000..d2d89040 --- /dev/null +++ b/app/app/Services/Billing/Invoice/PdfRenderer.php @@ -0,0 +1,26 @@ + $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; + } +} diff --git a/app/app/Services/Billing/Invoice/RequisitesIncompleteException.php b/app/app/Services/Billing/Invoice/RequisitesIncompleteException.php new file mode 100644 index 00000000..e48f9255 --- /dev/null +++ b/app/app/Services/Billing/Invoice/RequisitesIncompleteException.php @@ -0,0 +1,12 @@ +=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", diff --git a/app/config/dompdf.php b/app/config/dompdf.php new file mode 100644 index 00000000..eab21c75 --- /dev/null +++ b/app/config/dompdf.php @@ -0,0 +1,301 @@ + 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, 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 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 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, + ], + +]; diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 5963bb5e..17726585 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -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 diff --git a/app/resources/js/api/admin.ts b/app/resources/js/api/admin.ts index a0d2d340..c6e109f5 100644 --- a/app/resources/js/api/admin.ts +++ b/app/resources/js/api/admin.ts @@ -576,3 +576,39 @@ export async function executePdErasure(id: number, adminUserId?: number): Promis const { data } = await apiClient.post(`/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 { + const { data } = await apiClient.get('/api/admin/invoices', { params }); + return data; +} + +export async function markInvoicePaid(id: number): Promise { + await ensureCsrfCookie(); + await apiClient.post(`/api/admin/invoices/${id}/mark-paid`); +} diff --git a/app/resources/js/api/billing.ts b/app/resources/js/api/billing.ts index 0867a662..21659abf 100644 --- a/app/resources/js/api/billing.ts +++ b/app/resources/js/api/billing.ts @@ -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 { + 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 (мгновенное зачисление); diff --git a/app/resources/js/components/billing/InvoicesTable.vue b/app/resources/js/components/billing/InvoicesTable.vue index 1582d664..f349d3b8 100644 --- a/app/resources/js/components/billing/InvoicesTable.vue +++ b/app/resources/js/components/billing/InvoicesTable.vue @@ -61,7 +61,7 @@ defineExpose({ load, invoices });
- Счета появятся после первой оплаты. + Здесь появятся выставленные вами счета на оплату.
    @@ -72,9 +72,30 @@ defineExpose({ load, invoices }); {{ statusLabel(inv.status) }} {{ formatPlain(Number(inv.amount_total)) }} - - PDF - + + + Счёт + + + Акт + +
@@ -141,4 +162,9 @@ defineExpose({ load, invoices }); font-weight: 500; color: #081319; } +.inv-actions { + display: flex; + gap: 4px; + justify-content: flex-end; +} diff --git a/app/resources/js/components/billing/TopupDialog.vue b/app/resources/js/components/billing/TopupDialog.vue index 82bca0eb..326629b5 100644 --- a/app/resources/js/components/billing/TopupDialog.vue +++ b/app/resources/js/components/billing/TopupDialog.vue @@ -1,21 +1,24 @@ + + + + - + Баланс пополнен. + {{ invoiceMsg }} diff --git a/app/resources/js/views/admin/AdminInvoicesView.vue b/app/resources/js/views/admin/AdminInvoicesView.vue new file mode 100644 index 00000000..b13bdbee --- /dev/null +++ b/app/resources/js/views/admin/AdminInvoicesView.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/app/resources/views/pdf/act.blade.php b/app/resources/views/pdf/act.blade.php new file mode 100644 index 00000000..e264f792 --- /dev/null +++ b/app/resources/views/pdf/act.blade.php @@ -0,0 +1,38 @@ + + + + + + + +

Акт № {{ $act->upd_number }} от {{ \Illuminate\Support\Carbon::parse($act->issued_at)->format('d.m.Y') }}

+ +

Исполнитель: {{ $seller->name }}, ИНН {{ $seller->inn }}{{ $seller->kpp ? ', КПП '.$seller->kpp : '' }}

+

Заказчик: {{ $act->buyer_name }}, ИНН {{ $act->buyer_inn }}{{ $act->buyer_kpp ? ', КПП '.$act->buyer_kpp : '' }}

+

Основание: счёт № {{ $invoiceNumber }}

+ + + + +
Наименование услугиСумма
1Оплата генерации рекламных лидов{{ number_format((float) $act->amount_total, 2, '.', ' ') }} ₽
+ +

Всего оказано услуг на сумму: {{ number_format((float) $act->amount_total, 2, '.', ' ') }} ₽
Без НДС

+

Вышеперечисленные услуги оказаны полностью и в срок. Заказчик претензий по объёму, качеству и срокам оказания услуг не имеет.

+ + + + + + +
Исполнитель

_______________ / {{ $seller->director_name ?? $seller->name }}
Заказчик

_______________ / {{ $act->buyer_name }}
+ + diff --git a/app/resources/views/pdf/invoice.blade.php b/app/resources/views/pdf/invoice.blade.php new file mode 100644 index 00000000..335dc368 --- /dev/null +++ b/app/resources/views/pdf/invoice.blade.php @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + +
{{ $seller->bank_name }}БИК{{ $seller->bank_bik }}
Сч. №{{ $seller->bank_corr }}
Получатель
{{ $seller->name }}
ИНН {{ $seller->inn }} {{ $seller->kpp ? 'КПП '.$seller->kpp : '' }}
Сч. №{{ $seller->bank_account }}
+ +

Счёт на оплату № {{ $invoice->invoice_number }} от {{ \Illuminate\Support\Carbon::parse($invoice->issued_at)->format('d.m.Y') }}

+ +

Поставщик (Исполнитель): {{ $seller->name }}, ИНН {{ $seller->inn }}{{ $seller->kpp ? ', КПП '.$seller->kpp : '' }}{{ $seller->legal_address ? ', '.$seller->legal_address : '' }}

+

Покупатель (Заказчик): {{ $invoice->payer_name }}, ИНН {{ $invoice->payer_inn }}{{ $invoice->payer_kpp ? ', КПП '.$invoice->payer_kpp : '' }}{{ $invoice->payer_address ? ', '.$invoice->payer_address : '' }}

+ + + + @foreach($items as $i => $it) + + + + + + + + + @endforeach +
НаименованиеКол-воЕд.ЦенаСумма
{{ $i + 1 }}{{ $it->name }}{{ (int) $it->quantity }}{{ $it->unit }}{{ number_format((float) $it->price, 2, '.', ' ') }}{{ number_format((float) $it->amount_total, 2, '.', ' ') }}
+ +

Итого: {{ number_format((float) $invoice->amount_total, 2, '.', ' ') }} ₽
Без НДС

+

Назначение платежа: {{ $invoice->payment_purpose }}

+

Оплатить до: {{ \Illuminate\Support\Carbon::parse($invoice->expires_at)->format('d.m.Y') }}

+ + diff --git a/app/routes/console.php b/app/routes/console.php index 9cc7569f..b992d7f4 100644 --- a/app/routes/console.php +++ b/app/routes/console.php @@ -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). diff --git a/app/routes/web.php b/app/routes/web.php index d0e092f4..c294bf87 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -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'); diff --git a/app/tests/Feature/Billing/AdminInvoiceIndexTest.php b/app/tests/Feature/Billing/AdminInvoiceIndexTest.php new file mode 100644 index 00000000..99889e22 --- /dev/null +++ b/app/tests/Feature/Billing/AdminInvoiceIndexTest.php @@ -0,0 +1,49 @@ + $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'); +}); diff --git a/app/tests/Feature/Billing/ExpireInvoicesTest.php b/app/tests/Feature/Billing/ExpireInvoicesTest.php new file mode 100644 index 00000000..3aba31f0 --- /dev/null +++ b/app/tests/Feature/Billing/ExpireInvoicesTest.php @@ -0,0 +1,33 @@ + $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'); +}); diff --git a/app/tests/Feature/Billing/InvoiceCreateTest.php b/app/tests/Feature/Billing/InvoiceCreateTest.php new file mode 100644 index 00000000..937d6ae6 --- /dev/null +++ b/app/tests/Feature/Billing/InvoiceCreateTest.php @@ -0,0 +1,102 @@ + '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'); +}); diff --git a/app/tests/Feature/Billing/InvoiceMarkPaidTest.php b/app/tests/Feature/Billing/InvoiceMarkPaidTest.php new file mode 100644 index 00000000..e0acad1c --- /dev/null +++ b/app/tests/Feature/Billing/InvoiceMarkPaidTest.php @@ -0,0 +1,69 @@ +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); +}); diff --git a/app/tests/Feature/Billing/InvoiceNumberGeneratorTest.php b/app/tests/Feature/Billing/InvoiceNumberGeneratorTest.php new file mode 100644 index 00000000..bd74d2cf --- /dev/null +++ b/app/tests/Feature/Billing/InvoiceNumberGeneratorTest.php @@ -0,0 +1,59 @@ + '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'); +}); diff --git a/app/tests/Frontend/AdminInvoicesView.spec.ts b/app/tests/Frontend/AdminInvoicesView.spec.ts new file mode 100644 index 00000000..ad30fbd5 --- /dev/null +++ b/app/tests/Frontend/AdminInvoicesView.spec.ts @@ -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: '
', + 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); + }); +}); diff --git a/app/tests/Frontend/BillingInvoices.spec.ts b/app/tests/Frontend/BillingInvoices.spec.ts new file mode 100644 index 00000000..a2cadbbb --- /dev/null +++ b/app/tests/Frontend/BillingInvoices.spec.ts @@ -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 { + 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('появятся'); + }); +}); diff --git a/app/tests/Frontend/InvoicesTable.spec.ts b/app/tests/Frontend/InvoicesTable.spec.ts index edbd883d..8366ac68 100644 --- a/app/tests/Frontend/InvoicesTable.spec.ts +++ b/app/tests/Frontend/InvoicesTable.spec.ts @@ -9,24 +9,32 @@ vi.mock('../../resources/js/api/billing'); const vuetify = createVuetify(); +function inv(over: Partial = {}): 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?₽/); diff --git a/app/tests/Frontend/TopupDialogInvoice.spec.ts b/app/tests/Frontend/TopupDialogInvoice.spec.ts new file mode 100644 index 00000000..e925dae9 --- /dev/null +++ b/app/tests/Frontend/TopupDialogInvoice.spec.ts @@ -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 }; + 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 }; + vm.method = 'card'; + vm.amount = 2000; + await vm.submit(); + await flushPromises(); + + expect(topupSpy).toHaveBeenCalledWith(2000); + expect(invoiceSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/docs/superpowers/plans/2026-06-29-oplata-po-schetu-etap1.md b/docs/superpowers/plans/2026-06-29-oplata-po-schetu-etap1.md new file mode 100644 index 00000000..23d4ab9c --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-oplata-po-schetu-etap1.md @@ -0,0 +1,1339 @@ +# Оплата по счёту (Этап 1) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Клиент-юрлицо самостоятельно формирует счёт на пополнение баланса (PDF), оплачивает банковским переводом по реквизитам ВТБ; администратор отмечает оплату одной кнопкой → баланс зачисляется, формируется Акт (без НДС), клиент получает письмо. + +**Architecture:** Переиспользуем существующие таблицы (`saas_invoices`, `saas_invoice_items`, `saas_upd_documents`, `saas_transactions`), сервис зачисления `BillingTopupService::topup()` и письмо `InvoicePaidNotification`. Добавляем PDF-генерацию (dompdf), сервисы создания счёта/акта/отметки оплаты, клиентские и админ-эндпоинты, экраны Vue. Схема БД НЕ меняется. + +**Tech Stack:** PHP 8.3 / Laravel 13, PostgreSQL 16 (RLS), Vue 3 + Vuetify 3, Pest 4 (backend), Vitest (frontend), barryvdh/laravel-dompdf. + +**Спека:** [docs/superpowers/specs/2026-06-29-oplata-po-schetu-design.md](../specs/2026-06-29-oplata-po-schetu-design.md) + +**Рабочая директория кода:** `app/` (Laravel-корень). Все пути ниже — относительно `app/`, если не указано иное. + +--- + +## Карта файлов + +**Создаём:** +- `app/Services/Billing/Invoice/InvoiceNumberGenerator.php` — атомарная нумерация `СЧ-ГГГГ-NNNNN` по `legal_entity_id`. +- `app/Services/Billing/Invoice/InvoiceService.php` — создание счёта + позиции + PDF. +- `app/Services/Billing/Invoice/ActService.php` — создание Акта (закрывающий, без НДС) + PDF. +- `app/Services/Billing/Invoice/InvoicePaymentService.php` — отметка оплаты (зачисление + акт + письмо, идемпотентно). +- `app/Services/Billing/Invoice/PdfRenderer.php` — обёртка dompdf (Blade→PDF, сохранение в storage). +- `app/Http/Controllers/Api/InvoiceController.php` — клиентские эндпоинты счетов. +- `app/Http/Controllers/Api/AdminInvoiceController.php` — админ список + mark-paid. +- `app/Models/SaasInvoice.php`, `app/Models/SaasInvoiceItem.php`, `app/Models/SaasUpdDocument.php` — Eloquent-модели (если отсутствуют — проверить `app/Models/`). +- `resources/views/pdf/invoice.blade.php` — шаблон счёта. +- `resources/views/pdf/act.blade.php` — шаблон акта. +- `resources/js/views/admin/AdminInvoicesView.vue` — админ-экран «Счета». +- Тесты: `tests/Unit/Billing/InvoiceNumberGeneratorTest.php`, `tests/Feature/Billing/InvoiceCreateTest.php`, `tests/Feature/Billing/InvoiceMarkPaidTest.php`, `tests/Feature/Billing/AdminInvoiceIndexTest.php`, `tests/Frontend/TopupDialogInvoice.spec.ts`, `tests/Frontend/AdminInvoicesView.spec.ts`. + +**Модифицируем:** +- `composer.json` — +dompdf. +- `routes/web.php` — клиентские роуты в группе `/api/billing` (строка ~235) + админ в группе `saas-admin`/`admin-db` (строка ~111). +- `app/Http/Controllers/Api/BillingController.php:301` — расширить `invoices()` (добавить `has_act`, `act` ссылки, `expires_at`). +- `resources/js/components/billing/TopupDialog.vue` — вкладка «По счёту». +- `resources/js/api/admin.ts` + клиентский billing api-модуль — методы. + +--- + +## Предусловие (данные, не код) — собрать у владельца ПЕРЕД демо + +В `legal_entities` должна быть строка нашего ИП с `is_default=true`: `name`, `inn`, `bank_account`, `bank_bik`, `legal_address`, банк (ВТБ). Без неё шапка счёта пустая. На демо использовать тестовые реквизиты, для прода — реальные от владельца. + +--- + +## Task 1: Установка dompdf + +**Files:** +- Modify: `app/composer.json` +- Create: `app/config/dompdf.php` (публикуется пакетом) + +- [ ] **Step 1: Установить пакет** + +Run (в `app/`): +```bash +composer require barryvdh/laravel-dompdf:^3.1 +``` +Expected: пакет добавлен в `require`, `barryvdh/laravel-dompdf` в composer.lock. + +- [ ] **Step 2: Опубликовать конфиг и включить шрифт с кириллицей** + +Run: +```bash +php artisan vendor:publish --provider="Barryvdh\DomPDF\ServiceProvider" +``` +В `config/dompdf.php` убедиться: `'default_font' => 'dejavu sans'` (DejaVu Sans поддерживает кириллицу из коробки в dompdf). + +- [ ] **Step 3: Smoke-проверка рендера** + +Run: +```bash +php artisan tinker --execute="echo strlen(\Barryvdh\DomPDF\Facade\Pdf::loadHTML('

Тест кириллица

')->output());" +``` +Expected: число > 1000 (PDF сгенерирован, не пусто). + +- [ ] **Step 4: Commit** (по эскейпу — см. «Правила коммитов» в конце) + +```bash +git add app/composer.json app/composer.lock app/config/dompdf.php +git commit -m "chore(billing): add dompdf for invoice/act PDF generation" +``` + +--- + +## Task 2: InvoiceNumberGenerator — атомарная нумерация + +Формат `СЧ-ГГГГ-NNNNN` (год по Europe/Moscow), последовательная в пределах `legal_entity_id`+год, без дыр и гонок. Используем PostgreSQL advisory lock на пару (legal_entity_id, year), затем MAX+1 среди счетов этого юрлица за год. + +**Files:** +- Create: `app/Services/Billing/Invoice/InvoiceNumberGenerator.php` +- Test: `tests/Unit/Billing/InvoiceNumberGeneratorTest.php` + +- [ ] **Step 1: Failing test** + +```php +next(legalEntityId: 1, now: \Illuminate\Support\Carbon::parse('2026-06-29 12:00:00')); + expect($num)->toBe('СЧ-2026-00001'); +}); + +it('следующий номер инкрементируется по существующим счетам того же юрлица/года', function () { + SaasInvoice::factory()->create([ + 'legal_entity_id' => 1, + 'invoice_number' => 'СЧ-2026-00007', + 'issued_at' => '2026-03-01 00:00:00', + ]); + $gen = new InvoiceNumberGenerator; + $num = $gen->next(legalEntityId: 1, now: \Illuminate\Support\Carbon::parse('2026-06-29 12:00:00')); + expect($num)->toBe('СЧ-2026-00008'); +}); +``` + +- [ ] **Step 2: Run, verify FAIL** + +Run: `php artisan test tests/Unit/Billing/InvoiceNumberGeneratorTest.php` +Expected: FAIL «Class InvoiceNumberGenerator not found». + +- [ ] **Step 3: Implement** + +```php +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); + } +} +``` + +- [ ] **Step 4: Создать фабрику если нужно** + +Если `database/factories/SaasInvoiceFactory.php` отсутствует — создать с дефолтами всех NOT NULL полей (`tenant_id`, `legal_entity_id`, `invoice_number`, `payer_type='legal'`, `amount_net`, `amount_total`, `expires_at`). Модель `SaasInvoice` создаётся в Task 3. + +- [ ] **Step 5: Run, verify PASS** + +Run: `php artisan test tests/Unit/Billing/InvoiceNumberGeneratorTest.php` +Expected: PASS (2 теста). Если падает на отсутствии модели — выполнить Task 3 Step «модели» первым, вернуться. + +- [ ] **Step 6: Commit** + +```bash +git add app/app/Services/Billing/Invoice/InvoiceNumberGenerator.php app/tests/Unit/Billing/InvoiceNumberGeneratorTest.php app/database/factories/SaasInvoiceFactory.php +git commit -m "feat(billing): atomic invoice number generator СЧ-ГГГГ-NNNNN" +``` + +--- + +## Task 3: Eloquent-модели счёта/позиции/акта + +**Files:** +- Create: `app/Models/SaasInvoice.php`, `app/Models/SaasInvoiceItem.php`, `app/Models/SaasUpdDocument.php` +- (проверить `app/Models/` — если уже есть, пропустить создание, дополнить fillable/casts) + +- [ ] **Step 1: Проверить наличие** + +Run: `ls app/app/Models/ | grep -i -E "SaasInvoice|SaasUpd"` +Expected: вероятно пусто → создаём. + +- [ ] **Step 2: SaasInvoice** + +```php + 'decimal:2', 'vat_amount' => 'decimal:2', 'amount_total' => 'decimal:2', + 'issued_at' => 'datetime', 'expires_at' => 'datetime', 'paid_at' => 'datetime', 'cancelled_at' => 'datetime', + ]; + + public function items() + { + return $this->hasMany(SaasInvoiceItem::class, 'invoice_id'); + } +} +``` + +- [ ] **Step 3: SaasInvoiceItem** + +```php + 'decimal:3', 'price' => 'decimal:2', + 'amount_net' => 'decimal:2', 'amount_total' => 'decimal:2', + ]; +} +``` + +- [ ] **Step 4: SaasUpdDocument** + +```php + 'decimal:2', 'amount_total' => 'decimal:2', 'issued_at' => 'datetime', + ]; +} +``` + +- [ ] **Step 5: Verify** + +Run: `php artisan test tests/Unit/Billing/InvoiceNumberGeneratorTest.php` +Expected: PASS (теперь модель есть). + +- [ ] **Step 6: Commit** + +```bash +git add app/app/Models/SaasInvoice.php app/app/Models/SaasInvoiceItem.php app/app/Models/SaasUpdDocument.php +git commit -m "feat(billing): Eloquent models for saas_invoices/items/upd" +``` + +--- + +## Task 4: PdfRenderer — обёртка dompdf + +**Files:** +- Create: `app/Services/Billing/Invoice/PdfRenderer.php` + +- [ ] **Step 1: Implement (тонкая обёртка, без отдельного теста — покрывается feature-тестами рендера)** + +```php + $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; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add app/app/Services/Billing/Invoice/PdfRenderer.php +git commit -m "feat(billing): PdfRenderer wrapper around dompdf" +``` + +--- + +## Task 5: Blade-шаблоны счёта и акта + +**Files:** +- Create: `resources/views/pdf/invoice.blade.php`, `resources/views/pdf/act.blade.php` + +- [ ] **Step 1: invoice.blade.php** + +Шаблон российского счёта на оплату. Переменные: `$invoice` (SaasInvoice), `$items` (коллекция), `$seller` (LegalEntity — наш ИП). Все суммы — «Без НДС». + +```blade + + + + + + + + +
{{ $seller->bank_name ?? '' }}БИК{{ $seller->bank_bik ?? '' }}
Сч. №{{ $seller->bank_account ?? '' }}
Получатель
{{ $seller->name }}
ИНН {{ $seller->inn }}@if($seller->kpp) КПП {{ $seller->kpp }}@endif
Сч. №{{ $seller->settlement_account ?? $seller->bank_account ?? '' }}
+

Счёт на оплату № {{ $invoice->invoice_number }} от {{ \Illuminate\Support\Carbon::parse($invoice->issued_at)->format('d.m.Y') }}

+

Поставщик: {{ $seller->name }}, ИНН {{ $seller->inn }}, {{ $seller->legal_address ?? '' }}

+

Покупатель: {{ $invoice->payer_name }}, ИНН {{ $invoice->payer_inn }}@if($invoice->payer_kpp), КПП {{ $invoice->payer_kpp }}@endif, {{ $invoice->payer_address }}

+ + + @foreach($items as $i => $it) + + @endforeach +
НаименованиеКол-воЕд.ЦенаСумма
{{ $i+1 }}{{ $it->name }}{{ $it->quantity }}{{ $it->unit }}{{ $it->price }}{{ $it->amount_total }}
+

Итого: {{ $invoice->amount_total }} ₽
Без НДС

+

Назначение платежа: {{ $invoice->payment_purpose }}

+

Оплатить до: {{ \Illuminate\Support\Carbon::parse($invoice->expires_at)->format('d.m.Y') }}

+ +``` + +- [ ] **Step 2: act.blade.php** + +```blade + + +

Акт № {{ $act->upd_number }} от {{ \Illuminate\Support\Carbon::parse($act->issued_at)->format('d.m.Y') }}

+

Исполнитель: {{ $seller->name }}, ИНН {{ $seller->inn }}

+

Заказчик: {{ $act->buyer_name }}, ИНН {{ $act->buyer_inn }}

+

Основание: счёт № {{ $invoiceNumber }}

+ + + +
Наименование услугиСумма
1Пополнение баланса Лидерра{{ $act->amount_total }} ₽
+

Всего оказано услуг на сумму: {{ $act->amount_total }} ₽
Без НДС

+

Услуги оказаны полностью и в срок. Заказчик претензий по объёму, качеству и срокам не имеет.

+ +``` + +- [ ] **Step 3: Commit** + +```bash +git add app/resources/views/pdf/invoice.blade.php app/resources/views/pdf/act.blade.php +git commit -m "feat(billing): PDF Blade templates for invoice and act" +``` + +> Примечание полей `legal_entities`: проверить реальные имена колонок банка (`bank_name`, `bank_bik`, `bank_account`, `settlement_account`?) по `db/schema.sql:280`. Если `settlement_account` нет — убрать из шаблона. Не выдумывать колонки. + +--- + +## Task 6: InvoiceService — создание счёта + +**Files:** +- Create: `app/Services/Billing/Invoice/InvoiceService.php` +- Test: `tests/Feature/Billing/InvoiceCreateTest.php` + +- [ ] **Step 1: Failing feature-тест** + +```php +create(); + LegalEntity::factory()->create(['is_default' => true, 'inn' => '7700000000', 'name' => 'ИП Тест']); + TenantRequisites::factory()->create([ + 'tenant_id' => $tenant->id, 'subject_type' => 'legal_entity', + 'inn' => '5000000000', 'organization_name' => 'ООО Клиент', 'bank_account' => '40702810000000000001', + ]); + + $invoice = app(InvoiceService::class)->create($tenant->id, '1500.00', $tenant->id); + + expect($invoice->status)->toBe(SaasInvoice::STATUS_ISSUED) + ->and($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->items()->count())->toBe(1) + ->and($invoice->payment_purpose)->toContain($invoice->invoice_number); +}); + +it('бросает доменную ошибку если реквизиты клиента не заполнены', function () { + $tenant = Tenant::factory()->create(); + LegalEntity::factory()->create(['is_default' => true]); + app(InvoiceService::class)->create($tenant->id, '1500.00', $tenant->id); +})->throws(\App\Services\Billing\Invoice\RequisitesIncompleteException::class); +``` + +- [ ] **Step 2: Run, verify FAIL** — `php artisan test tests/Feature/Billing/InvoiceCreateTest.php` → FAIL (классов нет). + +- [ ] **Step 3: Implement exception** + +```php +first(); + if ($req === null || blank($req->inn)) { + throw new RequisitesIncompleteException('Заполните реквизиты компании.'); + } + + $seller = LegalEntity::where('is_default', true)->firstOrFail(); + + return DB::transaction(function () use ($tenantId, $amountRub, $req, $seller) { + $now = Carbon::now('Europe/Moscow'); + $number = $this->numbers->next($seller->id, $now); + + $invoice = SaasInvoice::create([ + 'tenant_id' => $tenantId, + 'legal_entity_id' => $seller->id, + 'invoice_number' => $number, + 'payer_type' => $req->subject_type === 'legal_entity' ? 'legal' : 'individual', + 'payer_name' => $req->organization_name ?? $req->contact_name, + 'payer_inn' => $req->inn, + 'payer_kpp' => $req->kpp, + 'payer_address' => $req->legal_address, + 'payer_email' => $req->contact_email ?? null, + 'amount_net' => $amountRub, + 'vat_rate' => 0, + 'vat_amount' => 0, + 'amount_total' => $amountRub, + 'payment_purpose' => "Оплата по счёту {$number}. Пополнение баланса Лидерра. Без НДС.", + 'status' => SaasInvoice::STATUS_ISSUED, + 'issued_at' => $now, + 'expires_at' => $now->copy()->addWeekdays(5), + ]); + + SaasInvoiceItem::create([ + 'invoice_id' => $invoice->id, + '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; + }); + } +} +``` + +> Проверить реальные имена колонок `tenant_requisites` (`organization_name`? `contact_email`? `legal_address`?) по `db/schema.sql:751`. Подставить фактические; не выдумывать. + +- [ ] **Step 5: Run, verify PASS** — `php artisan test tests/Feature/Billing/InvoiceCreateTest.php`. При падении на именах колонок — поправить fillable/маппинг по схеме, повторить. + +- [ ] **Step 6: Commit** + +```bash +git add app/app/Services/Billing/Invoice/InvoiceService.php app/app/Services/Billing/Invoice/RequisitesIncompleteException.php app/tests/Feature/Billing/InvoiceCreateTest.php +git commit -m "feat(billing): InvoiceService — create issued invoice + items + PDF" +``` + +--- + +## Task 7: ActService — закрывающий документ (Акт, без НДС) + +**Files:** +- Create: `app/Services/Billing/Invoice/ActService.php` + +- [ ] **Step 1: Implement (тестируется внутри InvoiceMarkPaidTest, Task 8)** + +```php +legal_entity_id); + $now = Carbon::now('Europe/Moscow'); + $number = str_replace('СЧ-', 'АКТ-', $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; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add app/app/Services/Billing/Invoice/ActService.php +git commit -m "feat(billing): ActService — closing act (no VAT) PDF" +``` + +--- + +## Task 8: InvoicePaymentService — отметка оплаты (зачисление + акт + письмо) + +Образец идемпотентности и RLS-контекста — `PaymentWebhookController::receive()`. + +**Files:** +- Create: `app/Services/Billing/Invoice/InvoicePaymentService.php` +- Test: `tests/Feature/Billing/InvoiceMarkPaidTest.php` + +- [ ] **Step 1: Failing test** + +```php +create(['balance_rub' => '100.00']); + LegalEntity::factory()->create(['is_default' => true]); + $invoice = SaasInvoice::factory()->create([ + 'tenant_id' => $tenant->id, 'status' => SaasInvoice::STATUS_ISSUED, + 'amount_total' => '1500.00', 'amount_net' => '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); + Mail::assertQueued(InvoicePaidNotification::class); +}); + +it('повторный mark-paid идемпотентен — баланс не удваивается', function () { + Mail::fake(); + $tenant = Tenant::factory()->create(['balance_rub' => '0.00']); + LegalEntity::factory()->create(['is_default' => true]); + $invoice = SaasInvoice::factory()->create([ + 'tenant_id' => $tenant->id, 'status' => SaasInvoice::STATUS_ISSUED, + 'amount_total' => '500.00', 'amount_net' => '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); +}); +``` + +- [ ] **Step 2: Run, verify FAIL.** + +- [ ] **Step 3: Implement** + +```php +tenant_id); + + // Атомарный claim issued→paid; 0 строк = уже оплачен (идемпотентный no-op). + $claimed = SaasInvoice::where('id', $invoice->id) + ->where('status', SaasInvoice::STATUS_ISSUED) + ->update(['status' => SaasInvoice::STATUS_PAID, 'paid_at' => now()]); + if ($claimed === 0) { + return; + } + + $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]); + + // Письмо клиенту (после COMMIT — через afterCommit Mailable/queue). + $tenant = Tenant::find($invoice->tenant_id); + $recipient = User::where('tenant_id', $invoice->tenant_id)->orderBy('id')->first(); + if ($tenant !== null && $recipient !== null) { + Mail::to($recipient->email)->queue(new InvoicePaidNotification( + $recipient, $tenant, (string) $invoice->amount_total, $invoice->invoice_number, null + )); + } + }); + } +} +``` + +> Проверить enum `saas_transactions.type` — допускает `'topup'` (схема: CHECK type IN topup/refund/manual_credit/manual_debit ✓). `gateway_code` nullable ✓. + +- [ ] **Step 4: Run, verify PASS** (2 теста). Если RLS/SET LOCAL мешает в тест-БД (суперюзер) — тесты всё равно проходят (GUC игнор без политики). + +- [ ] **Step 5: rls-reviewer** + +Запустить агента `rls-reviewer` на изменения (зачисление под admin-соединением с SET LOCAL). Учесть его замечания (особенно: корректный tenant-контекст при admin-db соединении в Task 9). + +- [ ] **Step 6: Commit** + +```bash +git add app/app/Services/Billing/Invoice/InvoicePaymentService.php app/tests/Feature/Billing/InvoiceMarkPaidTest.php +git commit -m "feat(billing): InvoicePaymentService — idempotent mark-paid + credit + act + email" +``` + +--- + +## Task 9: Клиентский InvoiceController + роуты + +**Files:** +- Create: `app/Http/Controllers/Api/InvoiceController.php` +- Modify: `routes/web.php` (группа `/api/billing`, ~235) + `BillingController::invoices()` (расширить ответ) + +- [ ] **Step 1: Failing test (создание + 422)** — добавить в `tests/Feature/Billing/InvoiceCreateTest.php`: + +```php +it('POST /api/billing/invoices создаёт счёт и возвращает 201 с pdf-ссылкой', function () { + [$user, $tenant] = makeTenantUser(); // helper проекта; иначе Sanctum::actingAs + LegalEntity::factory()->create(['is_default' => true]); + TenantRequisites::factory()->create(['tenant_id' => $tenant->id, 'inn' => '5000000000', 'organization_name' => 'ООО К', 'bank_account' => '40702810000000000001', 'subject_type' => 'legal_entity']); + + $this->actingAs($user)->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 () { + [$user, $tenant] = makeTenantUser(); + LegalEntity::factory()->create(['is_default' => true]); + $this->actingAs($user)->postJson('/api/billing/invoices', ['amount_rub' => 2000]) + ->assertStatus(422); +}); +``` + +> Использовать существующий тест-хелпер аутентификации тенанта (поискать в `tests/` как делают `InvoiceCreateTest`-соседи, напр. `Sanctum::actingAs` + tenant middleware). Не выдумывать `makeTenantUser`, если его нет — заменить на фактический паттерн проекта. + +- [ ] **Step 2: Implement controller** + +```php +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): StreamedResponse + { + /** @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 Storage::disk('local')->download($invoice->pdf_path, "Счёт-{$invoice->invoice_number}.pdf"); + } + + public function act(Request $request, int $id): StreamedResponse + { + /** @var User $user */ + $user = $request->user(); + $invoice = SaasInvoice::where('id', $id)->where('tenant_id', $user->tenant_id)->firstOrFail(); + $act = \App\Models\SaasUpdDocument::where('invoice_id', $invoice->id)->firstOrFail(); + abort_if($act->pdf_path === null || ! Storage::disk('local')->exists($act->pdf_path), 404); + + return Storage::disk('local')->download($act->pdf_path, "Акт-{$act->upd_number}.pdf"); + } +} +``` + +- [ ] **Step 3: Routes** — в группе `/api/billing` (`routes/web.php` ~236) добавить: + +```php + 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'); +``` + +- [ ] **Step 4: Расширить `BillingController::invoices()`** — в `select` добавить `expires_at`, `paid_at`; в map добавить `expires_at`, `has_act` (есть ли saas_upd_documents по invoice_id), `pdf_url`/`act_url`. Конкретно: + +```php +$rows = DB::table('saas_invoices') + ->where('tenant_id', $tenantId) + ->orderBy('issued_at', 'desc') + ->get(['id', 'invoice_number', 'amount_total', 'status', 'issued_at', 'expires_at', 'pdf_path']); + +$actInvoiceIds = DB::table('saas_upd_documents') + ->where('tenant_id', $tenantId)->pluck('invoice_id')->filter()->flip(); + +return response()->json([ + 'data' => $rows->map(static fn (\stdClass $r): array => [ + 'id' => $r->id, + 'invoice_number' => $r->invoice_number, + '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(), +]); +``` + +- [ ] **Step 5: Run, verify PASS** — `php artisan test tests/Feature/Billing/InvoiceCreateTest.php`. + +- [ ] **Step 6: Commit** + +```bash +git add app/app/Http/Controllers/Api/InvoiceController.php app/routes/web.php app/app/Http/Controllers/Api/BillingController.php app/tests/Feature/Billing/InvoiceCreateTest.php +git commit -m "feat(billing): client invoice API — create/list/download" +``` + +--- + +## Task 10: Админский AdminInvoiceController + роуты + +**Files:** +- Create: `app/Http/Controllers/Api/AdminInvoiceController.php` +- Modify: `routes/web.php` (группа `saas-admin`/`admin-db`, ~111) +- Test: `tests/Feature/Billing/AdminInvoiceIndexTest.php` + +- [ ] **Step 1: Failing test (список + mark-paid)** + +```php +create(); + SaasInvoice::factory()->count(3)->create(['tenant_id' => $tenant->id, 'status' => 'issued']); + actingAsAdmin() // helper зоны saas-admin (см. соседние Admin*Test) + ->getJson('/api/admin/invoices?status=issued') + ->assertOk() + ->assertJsonStructure(['data' => [['id', 'invoice_number', 'amount_total', 'status', 'tenant_id']], 'meta' => ['total']]); +}); + +it('POST /api/admin/invoices/{id}/mark-paid зачисляет и ставит paid', function () { + $tenant = Tenant::factory()->create(['balance_rub' => '0.00']); + LegalEntity::factory()->create(['is_default' => true]); + $invoice = SaasInvoice::factory()->create(['tenant_id' => $tenant->id, 'status' => 'issued', 'amount_total' => '700.00', 'amount_net' => '700.00']); + + actingAsAdmin()->postJson("/api/admin/invoices/{$invoice->id}/mark-paid")->assertOk(); + + $tenant->refresh(); + expect($tenant->balance_rub)->toBe('700.00') + ->and(SaasInvoice::find($invoice->id)->status)->toBe('paid'); +}); +``` + +> `actingAsAdmin()` — использовать фактический паттерн зоны saas-admin из соседних тестов (`tests/Feature/.../Admin*Test.php`), не выдумывать. + +- [ ] **Step 2: Implement controller** (серверная пагинация/фильтр как `AdminTenantsController::index`) + +```php +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.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.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']); + } +} +``` + +- [ ] **Step 3: Routes** — в группе `saas-admin`/`admin-db` (`routes/web.php` ~138) добавить: + +```php + 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'); +``` + +- [ ] **Step 4: Run, verify PASS.** Если падает на larastan-baseline (новые `getJson`) — обновить `count` в `phpstan-baseline.neon` (см. урок про getJson count-pattern). + +- [ ] **Step 5: Commit** + +```bash +git add app/app/Http/Controllers/Api/AdminInvoiceController.php app/routes/web.php app/tests/Feature/Billing/AdminInvoiceIndexTest.php app/phpstan-baseline.neon +git commit -m "feat(billing): admin invoice API — list + mark-paid" +``` + +--- + +## Task 11: Frontend — вкладка «По счёту» в TopupDialog + +**Files:** +- Modify: `resources/js/components/billing/TopupDialog.vue` +- Modify: клиентский billing api-модуль (поискать `resources/js/api/billing.ts` или где `topup` определён) +- Test: `tests/Frontend/TopupDialogInvoice.spec.ts` + +- [ ] **Step 1: Прочитать `TopupDialog.vue`** — понять текущую разметку (способ оплаты картой), Vuetify-компоненты, где разместить переключатель «Карта / По счёту». + +- [ ] **Step 2: Failing vitest** — `tests/Frontend/TopupDialogInvoice.spec.ts`: + +```ts +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()); + + it('при выборе «По счёту» и сумме вызывает createInvoice', 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 w = mount(TopupDialog, { props: { modelValue: true }, global: { plugins: [createVuetify()] } }); + const vm = w.vm as any; + vm.method = 'invoice'; + vm.amount = 1500; + await vm.submit(); + await flushPromises(); + expect(spy).toHaveBeenCalledWith(1500); + }); +}); +``` + +> Подогнать имена `method`/`amount`/`submit` под фактический компонент после Step 1; defineExpose при необходимости. + +- [ ] **Step 3: api-метод** — добавить в billing api-модуль: + +```ts +export interface CreatedInvoice { id: number; invoice_number: string; amount_total: string; pdf_url: string; } +export async function createInvoice(amountRub: number): Promise { + const { data } = await axios.post('/api/billing/invoices', { amount_rub: amountRub }); + return data.invoice; +} +``` + +- [ ] **Step 4: UI** — в TopupDialog добавить `v-btn-toggle` «Карта / По счёту (для юрлиц)»; при `method==='invoice'` кнопка «Сформировать счёт» → `createInvoice(amount)` → открыть `pdf_url` (скачивание) + тост «Счёт сформирован». Если 422 (нет реквизитов) — показать сообщение и ссылку на заполнение реквизитов. Палитра Forest, Vuetify-only. + +- [ ] **Step 5: Run, verify PASS** — `npm run test:vue -- TopupDialogInvoice`. + +- [ ] **Step 6: Commit** + +```bash +git add app/resources/js/components/billing/TopupDialog.vue app/resources/js/api/billing.ts app/tests/Frontend/TopupDialogInvoice.spec.ts +git commit -m "feat(billing): TopupDialog invoice payment tab" +``` + +--- + +## Task 12: Frontend — «Мои счета» (список + скачивание) в BillingView + +**Files:** +- Modify: `resources/js/views/BillingView.vue` (раздел счетов; найти где зовётся `/api/billing/invoices`) +- Modify: billing api-модуль (`listInvoices`) + +- [ ] **Step 1: Прочитать `BillingView.vue`** — найти существующий блок счетов (real-but-empty). + +- [ ] **Step 2: api-метод** + +```ts +export interface ClientInvoice { id:number; invoice_number:string; amount_total:string; status:string; issued_at:string; expires_at:string|null; has_pdf:boolean; has_act:boolean; pdf_url:string|null; act_url:string|null; } +export async function listInvoices(): Promise { + const { data } = await axios.get('/api/billing/invoices'); + return data.data; +} +``` + +- [ ] **Step 3: UI** — таблица счетов: номер, сумма, статус (чип: Выставлен/Оплачен/Просрочен), дата, кнопки «Скачать счёт» (`pdf_url`) и «Скачать акт» (`act_url`, если `has_act`). Статус-чипы в палитре Forest. + +- [ ] **Step 4: vitest** — `tests/Frontend/BillingInvoices.spec.ts`: мокнуть `listInvoices`, проверить рендер статуса и наличие кнопки «Скачать акт» только при `has_act`. + +- [ ] **Step 5: Run PASS + Commit** + +```bash +git add app/resources/js/views/BillingView.vue app/resources/js/api/billing.ts app/tests/Frontend/BillingInvoices.spec.ts +git commit -m "feat(billing): client «Мои счета» list with downloads" +``` + +--- + +## Task 13: Frontend — Админ-экран «Счета» + +**Files:** +- Create: `resources/js/views/admin/AdminInvoicesView.vue` +- Modify: `resources/js/api/admin.ts` (методы `listAdminInvoices`, `markInvoicePaid`) +- Modify: `routes/web.php` (`Route::view('/admin/invoices', 'welcome')`) + фронт-роутер (`resources/js/router`) + пункт меню админки +- Test: `tests/Frontend/AdminInvoicesView.spec.ts` + +- [ ] **Step 1: api-методы** + +```ts +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 async function listAdminInvoices(p:{status?:string;search?:string;page?:number;perPage?:number}={}): Promise<{data:AdminInvoiceRow[];meta:{total:number;current_page:number;last_page:number;per_page:number}}> { + const { data } = await axios.get('/api/admin/invoices', { params: { status:p.status, search:p.search, page:p.page, per_page:p.perPage } }); + return data; +} +export async function markInvoicePaid(id:number): Promise { await axios.post(`/api/admin/invoices/${id}/mark-paid`); } +``` + +- [ ] **Step 2: Failing vitest** — `tests/Frontend/AdminInvoicesView.spec.ts`: + +```ts +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'; + +describe('AdminInvoicesView', () => { + beforeEach(() => vi.clearAllMocks()); + it('кнопка «Отметить оплаченным» открывает диалог и зовёт markInvoicePaid после подтверждения', async () => { + vi.spyOn(adminApi, 'listAdminInvoices').mockResolvedValue({ + 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 }, + }); + const spy = vi.spyOn(adminApi, 'markInvoicePaid').mockResolvedValue(); + const w = mount(AdminInvoicesView, { + global: { plugins: [createVuetify()], stubs: { VDialog: { template: '
', props: ['modelValue'] } } }, + }); + await flushPromises(); + await w.find('[data-testid="mark-paid-5"]').trigger('click'); + await w.vm.$nextTick(); + const confirm = w.findAll('button').find(b => b.text().includes('Подтверждаю')); + await confirm!.trigger('click'); + await flushPromises(); + expect(spy).toHaveBeenCalledWith(5); + }); +}); +``` + +- [ ] **Step 3: Implement AdminInvoicesView.vue** — таблица (серверная пагинация/поиск/фильтр статуса как `AdminTenantsView.vue`), у `issued`-строк кнопка `[data-testid="mark-paid-{id}"]` «Отметить оплаченным» → `v-dialog` подтверждения с суммой/клиентом/номером → `markInvoicePaid(id)` → перезагрузка списка + тост. Палитра Forest. + +- [ ] **Step 4: Роут + меню** — `Route::view('/admin/invoices', 'welcome')` в `routes/web.php`; запись в фронт-роутере; пункт «Счета» в навигации админки (рядом с «Биллинг»). + +- [ ] **Step 5: Run PASS + Commit** + +```bash +git add app/resources/js/views/admin/AdminInvoicesView.vue app/resources/js/api/admin.ts app/routes/web.php app/resources/js/router app/tests/Frontend/AdminInvoicesView.spec.ts +git commit -m "feat(billing): admin «Счета» screen with mark-paid" +``` + +--- + +## Task 14: Просрочка счёта (overdue) + +**Files:** +- Create: `app/Console/Commands/ExpireInvoicesCommand.php` +- Modify: `routes/console.php` или `app/Console/Kernel.php` (расписание — найти как регистрируются cron в проекте) + +- [ ] **Step 1: Failing test** — `tests/Feature/Billing/ExpireInvoicesTest.php`: + +```php +create(); + $stale = SaasInvoice::factory()->create(['tenant_id' => $t->id, 'status' => 'issued', 'expires_at' => now()->subDay()]); + $fresh = SaasInvoice::factory()->create(['tenant_id' => $t->id, 'status' => 'issued', 'expires_at' => now()->addDay()]); + $paid = SaasInvoice::factory()->create(['tenant_id' => $t->id, 'status' => 'paid', 'expires_at' => now()->subDay()]); + + $this->artisan('invoices:expire')->assertOk(); + + expect(SaasInvoice::find($stale->id)->status)->toBe('overdue') + ->and(SaasInvoice::find($fresh->id)->status)->toBe('issued') + ->and(SaasInvoice::find($paid->id)->status)->toBe('paid'); +}); +``` + +- [ ] **Step 2: Implement command** + +```php +where('expires_at', '<', now()) + ->update(['status' => SaasInvoice::STATUS_OVERDUE]); + + return self::SUCCESS; + } +} +``` + +- [ ] **Step 3: Расписание** — добавить `$schedule->command('invoices:expire')->dailyAt('03:30')` тем же способом, что зарегистрированы прочие cron проекта (проверить `routes/console.php` / `bootstrap/app.php` withSchedule). + +- [ ] **Step 4: Run PASS + Commit** + +```bash +git add app/app/Console/Commands/ExpireInvoicesCommand.php app/routes/console.php app/tests/Feature/Billing/ExpireInvoicesTest.php +git commit -m "feat(billing): invoices:expire — mark overdue unpaid invoices" +``` + +--- + +## Task 15: Полный прогон + демо + +- [ ] **Step 1: Backend full** — `composer test` (или `php artisan test`). Все зелёные. +- [ ] **Step 2: Frontend full** — `npm run test:vue`. Все зелёные. +- [ ] **Step 3: Линт/стат** — `composer pint`, `composer stan` (обновить baseline при новых getJson), `npm run lint:vue`. +- [ ] **Step 4: Локальное демо для владельца** (требование заказчика): + - изолированный сервер (как при демо «Тенанты»): `CAPTCHA_DRIVER=null CAPTCHA_FAKE_PASSES=true php artisan serve --port=8001`; + - засеять тестовый ИП в `legal_entities` (is_default) + тестового тенанта с реквизитами; + - пройти путь: клиент формирует счёт → скачивает PDF → админ «Счета» → «Отметить оплаченным» → проверить пополнение баланса + скачать акт + письмо (через `Mail::fake`/лог); + - снять 3-4 скрина, показать владельцу, дождаться «ок». +- [ ] **Step 5: Только после «ок» на демо** — деплой по эскейпу (отдельный заход, `bin/deploy-source-edit.sh`), предусловие: заполнить реальные реквизиты ИП в `legal_entities` на проде. + +--- + +## Правила коммитов и окружения (важно) + +- **Коммиты — только по эскейпу владельца** (CLAUDE.md §главное п.4/п.8). Шаги «Commit» в плане выполнять, запросив у владельца «эскейп». +- **Схему БД не трогаем** — Этап 1 переиспортирует существующие таблицы. Если внезапно понадобится колонка — STOP, спросить (правило §5 п.8: правка `schema.sql` только с CHANGELOG + rls-reviewer). +- **PDF в storage `local`** (приватно), отдаём только владельцу счёта (tenant-scoped) / админу. Не класть в public. +- **Тест-БД** — `liderra_testing` через `php artisan migrate`, НИКОГДА не прод. +- **Larastan baseline** — новые `getJson()` в тестах ломают count-pattern; обновлять `phpstan-baseline.neon`. +- **CLAUDE.md, NewProjectDialog.vue, db/CHANGELOG_schema.md, observer-файлы** — НЕ трогать (параллельная сессия). + +--- + +## Self-Review (выполнен) + +**1. Покрытие спеки:** §2 (переиспользование) — Tasks 3,8; §3.1 dompdf — Task 1; §3.2 сервисы — Tasks 2,6,7,8; §3.3 API — Tasks 9,10; §3.4 экраны — Tasks 11,12,13; §3.5 PDF-шаблоны — Task 5; §5 граничные (422, идемпотентность, overdue, нумерация) — Tasks 6,8,14,2; §6 тесты — в каждой задаче; §7 предусловие данных — отдельный блок + Task 15; §8 демо — Task 15. **Пробел:** Этап 2 — вне scope (намеренно). + +**2. Заглушки:** код приведён в каждом шаге; помечены 3 места «проверить фактические имена колонок по схеме» (`legal_entities` банк-поля, `tenant_requisites` поля, тест-хелперы аутентификации) — это требование сверки с реальностью, НЕ заглушка-плейсхолдер. + +**3. Консистентность типов:** `InvoiceNumberGenerator::next(int,?Carbon):string`, `InvoiceService::create(int,string,?int):SaasInvoice`, `ActService::createForInvoice(SaasInvoice,int):SaasUpdDocument`, `InvoicePaymentService::markPaid(int):void` — имена методов/полей согласованы между задачами и тестами. diff --git a/docs/superpowers/specs/2026-06-29-oplata-po-schetu-design.md b/docs/superpowers/specs/2026-06-29-oplata-po-schetu-design.md new file mode 100644 index 00000000..2e860c75 --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-oplata-po-schetu-design.md @@ -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`