Files
portal/app/app/Http/Controllers/Api/InvoiceController.php
T
Дмитрий cdfae077a3
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat(биллинг): оплата по счёту (Этап 1) — счёт, акт, отметка оплаты
Клиент сам выставляет PDF-счёт (TopupDialog вкладка «По счёту»), счета и
акты — в отдельной вкладке «Счета». Админ (/admin/invoices) отмечает оплату
одной кнопкой → атомарно зачисляет баланс (BillingTopupService), формирует
Акт (без НДС, saas_upd_documents ДОП) и шлёт клиенту письмо «Счёт оплачен»
с вложением PDF-акта. PDF открываются inline в браузере (ASCII-имя).

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

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

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

86 lines
3.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\SaasInvoice;
use App\Models\SaasUpdDocument;
use App\Models\User;
use App\Services\Billing\Invoice\InvoiceService;
use App\Services\Billing\Invoice\RequisitesIncompleteException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Клиентские эндпоинты «оплата по счёту» (под middleware auth:sanctum + tenant).
* Создание счёта (самообслуживание), скачивание PDF счёта и акта (tenant-scoped).
*/
class InvoiceController extends Controller
{
public function __construct(private readonly InvoiceService $invoices) {}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'amount_rub' => ['required', 'numeric', 'min:100', 'max:1000000', 'decimal:0,2'],
]);
/** @var User $user */
$user = $request->user();
$amountRub = bcadd((string) $validated['amount_rub'], '0', 2);
try {
$invoice = $this->invoices->create((int) $user->tenant_id, $amountRub, (int) $user->id);
} catch (RequisitesIncompleteException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json(['invoice' => [
'id' => $invoice->id,
'invoice_number' => $invoice->invoice_number,
'amount_total' => $invoice->amount_total,
'pdf_url' => "/api/billing/invoices/{$invoice->id}/pdf",
]], 201);
}
public function pdf(Request $request, int $id): Response
{
/** @var User $user */
$user = $request->user();
$invoice = SaasInvoice::where('id', $id)->where('tenant_id', $user->tenant_id)->firstOrFail();
abort_if($invoice->pdf_path === null || ! Storage::disk('local')->exists($invoice->pdf_path), 404);
return $this->inlinePdf($invoice->pdf_path, 'Schet-'.$invoice->invoice_number);
}
public function act(Request $request, int $id): Response
{
/** @var User $user */
$user = $request->user();
$invoice = SaasInvoice::where('id', $id)->where('tenant_id', $user->tenant_id)->firstOrFail();
$act = SaasUpdDocument::where('invoice_id', $invoice->id)->firstOrFail();
abort_if($act->pdf_path === null || ! Storage::disk('local')->exists($act->pdf_path), 404);
return $this->inlinePdf($act->pdf_path, 'Akt-'.$act->upd_number);
}
/**
* Отдать PDF для просмотра в браузере (inline) с ASCII-безопасным именем —
* кириллица в Content-Disposition ломала имя файла в браузере (random GUID).
*/
private function inlinePdf(string $path, string $baseName): Response
{
$content = Storage::disk('local')->get($path);
$filename = Str::ascii($baseName).'.pdf'; // напр. Schet-SCh-2026-00001.pdf
return response($content, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$filename.'"',
]);
}
}