cdfae077a3
Клиент сам выставляет 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>
86 lines
3.3 KiB
PHP
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.'"',
|
|
]);
|
|
}
|
|
}
|