From e5ab1f9e783b89996f5d377462e9446c81b6e4ee Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Sun, 1 Jun 2025 13:12:26 +0200 Subject: [PATCH] adds a route to download generated invoice documents --- src/API/InvoiceController.php | 29 +++++++++++++++++++++++-- src/Entity/Invoice.php | 2 ++ tests/API/APIControllerBaseTestCase.php | 1 + tests/API/InvoiceControllerTest.php | 5 +++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/API/InvoiceController.php b/src/API/InvoiceController.php index e860d62ea..af43df7fe 100644 --- a/src/API/InvoiceController.php +++ b/src/API/InvoiceController.php @@ -10,6 +10,7 @@ namespace App\API; use App\Entity\Invoice; +use App\Invoice\ServiceInvoice; use App\Repository\CustomerRepository; use App\Repository\InvoiceRepository; use App\Repository\Query\InvoiceArchiveQuery; @@ -18,6 +19,7 @@ use FOS\RestBundle\Request\ParamFetcherInterface; use FOS\RestBundle\View\View; use FOS\RestBundle\View\ViewHandlerInterface; use OpenApi\Attributes as OA; +use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; @@ -25,6 +27,7 @@ use Symfony\Component\Validator\Constraints; #[Route(path: '/invoices')] #[IsGranted('API')] +#[IsGranted('view_invoice')] #[OA\Tag(name: 'Invoice')] final class InvoiceController extends BaseApiController { @@ -40,7 +43,6 @@ final class InvoiceController extends BaseApiController /** * Fetch invoices */ - #[IsGranted('view_invoice')] #[OA\Response(response: 200, description: 'Returns a collection of invoices', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/InvoiceCollection')))] #[Route(methods: ['GET'], path: '', name: 'get_invoices')] #[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')] @@ -89,7 +91,6 @@ final class InvoiceController extends BaseApiController /** * Fetch invoice */ - #[IsGranted('view_invoice')] #[OA\Response(response: 200, description: 'Returns one invoice', content: new OA\JsonContent(ref: '#/components/schemas/Invoice'))] #[Route(methods: ['GET'], path: '/{id}', name: 'get_invoice', requirements: ['id' => '\d+'])] public function getAction(Invoice $invoice): Response @@ -99,4 +100,28 @@ final class InvoiceController extends BaseApiController return $this->viewHandler->handle($view); } + + /** + * Download invoice + * + * The returned `content-type` depends on the type of invoice document. + * Could be anything from `application/pdf` to `application/vnd.openxmlformats-officedocument.wordprocessingml.document`. + * + * Use the response header to detect the filename, e.g. `content-disposition: attachment; filename=2025-001-acmme_company.pdf`. + */ + #[OA\Response(response: 200, description: 'Returns the (binary) invoice document', content: new OA\MediaType(mediaType: 'application/octet-stream', schema: new OA\Schema(type: 'string', format: 'binary')))] + #[Route(path: '/{id}/download', requirements: ['id' => '\d+'], methods: ['GET'])] + #[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')] + public function download(Invoice $invoice, ServiceInvoice $service): Response + { + $file = $service->getInvoiceFile($invoice); + + if (null === $file) { + throw $this->createNotFoundException( + \sprintf('Invoice file "%s" could not be found for invoice ID "%s"', $invoice->getInvoiceFilename(), $invoice->getId()) + ); + } + + return $this->file($file->getRealPath(), $file->getBasename()); + } } diff --git a/src/Entity/Invoice.php b/src/Entity/Invoice.php index 1a24189f8..469a1e9a8 100644 --- a/src/Entity/Invoice.php +++ b/src/Entity/Invoice.php @@ -126,6 +126,8 @@ class Invoice implements EntityWithMetaFields #[ORM\Column(name: 'invoice_filename', type: Types::STRING, length: 150, nullable: false)] #[Assert\NotNull] #[Assert\Length(min: 1, max: 150)] + #[Serializer\Expose] + #[Serializer\Groups(['Default'])] #[Exporter\Expose(label: 'file', type: 'string')] private ?string $invoiceFilename = null; private bool $localized = false; diff --git a/tests/API/APIControllerBaseTestCase.php b/tests/API/APIControllerBaseTestCase.php index 8312f53ed..90b20e354 100644 --- a/tests/API/APIControllerBaseTestCase.php +++ b/tests/API/APIControllerBaseTestCase.php @@ -303,6 +303,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase 'user' => ['result' => 'object', 'type' => '@User'], 'dueDays' => 'int', 'invoiceNumber' => 'string', + 'invoiceFilename' => 'string', 'metaFields' => 'array', 'paymentDate' => '@datetime', 'status' => 'string', diff --git a/tests/API/InvoiceControllerTest.php b/tests/API/InvoiceControllerTest.php index d563af0dc..8b76e2e87 100644 --- a/tests/API/InvoiceControllerTest.php +++ b/tests/API/InvoiceControllerTest.php @@ -120,6 +120,11 @@ class InvoiceControllerTest extends APIControllerBaseTestCase self::assertApiResponseTypeStructure('Invoice', $result); } + public function testDownloadInvoice(): void + { + $this->fail('Missing test'); + } + public function testNotFound(): void { $this->assertEntityNotFound(User::ROLE_USER, '/api/invoices/' . PHP_INT_MAX);