Added API endpoints to fetch invoices (#5070)
* added serializer attributes for API usage * new setters to fill invoice data from fixture * re-usable fixture helper methods * added API endpoints to fetch invoices * adjust tests
This commit is contained in:
124
src/API/InvoiceController.php
Normal file
124
src/API/InvoiceController.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\API;
|
||||
|
||||
use App\Entity\Invoice;
|
||||
use App\Entity\User;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\InvoiceRepository;
|
||||
use App\Repository\Query\InvoiceArchiveQuery;
|
||||
use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\Validator\Constraints;
|
||||
|
||||
#[Route(path: '/invoices')]
|
||||
#[IsGranted('API')]
|
||||
#[OA\Tag(name: 'Invoice')]
|
||||
final class InvoiceController extends BaseApiController
|
||||
{
|
||||
public const GROUPS_ENTITY = ['Default', 'Entity', 'Invoice', 'Invoice_Entity'];
|
||||
public const GROUPS_COLLECTION = ['Default', 'Collection', 'Invoice'];
|
||||
|
||||
public function __construct(
|
||||
private readonly ViewHandlerInterface $viewHandler,
|
||||
private readonly InvoiceRepository $repository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a collection of invoices (which are visible to the user)
|
||||
*
|
||||
* Needs permission: view_invoice
|
||||
*/
|
||||
#[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)')]
|
||||
#[Rest\QueryParam(name: 'end', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records before this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
#[Rest\QueryParam(name: 'customers', map: true, requirements: '\d+', strict: true, nullable: true, default: [], description: 'List of customer IDs to filter, e.g.: customers[]=1&customers[]=2')]
|
||||
#[Rest\QueryParam(name: 'status', map: true, requirements: 'pending|paid|canceled|new', strict: true, nullable: true, default: [], description: 'Invoice status: pending, paid, canceled, new. Default: all')]
|
||||
#[Rest\QueryParam(name: 'page', requirements: '\d+', strict: true, nullable: true, description: 'The page to display, renders a 404 if not found (default: 1)')]
|
||||
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries for each page (default: 50)')]
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerRepository $customerRepository): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$query = new InvoiceArchiveQuery();
|
||||
$query->setCurrentUser($user);
|
||||
$factory = $this->getDateTimeFactory();
|
||||
|
||||
$begin = $paramFetcher->get('begin');
|
||||
if (\is_string($begin) && $begin !== '') {
|
||||
$query->setBegin($factory->createDateTime($begin));
|
||||
}
|
||||
|
||||
$end = $paramFetcher->get('end');
|
||||
if (\is_string($end) && $end !== '') {
|
||||
$query->setEnd($factory->createDateTime($end));
|
||||
}
|
||||
|
||||
/** @var array<string> $status */
|
||||
$status = $paramFetcher->get('status');
|
||||
if (\is_array($status)) {
|
||||
foreach ($status as $s) {
|
||||
$query->addStatus($s);
|
||||
}
|
||||
}
|
||||
|
||||
$page = $paramFetcher->get('page');
|
||||
if (\is_string($page) && $page !== '') {
|
||||
$query->setPage((int) $page);
|
||||
}
|
||||
|
||||
$size = $paramFetcher->get('size');
|
||||
if (is_numeric($size)) {
|
||||
$query->setPageSize((int) $size);
|
||||
}
|
||||
|
||||
/** @var array<int> $customers */
|
||||
$customers = $paramFetcher->get('customers');
|
||||
foreach ($customerRepository->findByIds(array_unique($customers)) as $customer) {
|
||||
$query->addCustomer($customer);
|
||||
}
|
||||
|
||||
$query->setIsApiCall(true);
|
||||
$data = $this->repository->getPagerfantaForQuery($query);
|
||||
$results = (array) $data->getCurrentPageResults();
|
||||
|
||||
$view = new View($results, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_COLLECTION);
|
||||
$this->addPagination($view, $data);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns one invoice.
|
||||
*
|
||||
* Needs permission: view_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
|
||||
{
|
||||
$view = new View($invoice, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity('invoiceNumber')]
|
||||
#[UniqueEntity('invoiceFilename')]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
#[Exporter\Order(['id', 'createdAt', 'invoiceNumber', 'status', 'customer', 'subtotal', 'total', 'tax', 'currency', 'vat', 'dueDays', 'dueDate', 'paymentDate', 'user', 'invoiceFilename', 'customerNumber', 'comment'])]
|
||||
#[Exporter\Expose(name: 'customer', label: 'customer', exp: 'object.getCustomer() === null ? null : object.getCustomer().getName()')]
|
||||
#[Exporter\Expose(name: 'customerNumber', label: 'number', exp: 'object.getCustomer() === null ? null : object.getCustomer().getNumber()')]
|
||||
@@ -44,56 +45,78 @@ class Invoice implements EntityWithMetaFields
|
||||
#[ORM\Column(name: 'id', type: 'integer')]
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'id', type: 'integer')]
|
||||
private ?int $id = null;
|
||||
#[ORM\Column(name: 'invoice_number', type: 'string', length: 50, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'invoice.number', type: 'string')]
|
||||
private ?string $invoiceNumber = null;
|
||||
#[ORM\Column(name: 'comment', type: 'text', nullable: true)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
#[Serializer\Groups(['Invoice'])]
|
||||
#[Exporter\Expose(label: 'comment')]
|
||||
private ?string $comment = null;
|
||||
#[ORM\ManyToOne(targetEntity: Customer::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?Customer $customer = null;
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?User $user = null;
|
||||
#[ORM\Column(name: 'created_at', type: 'datetime', nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'date', type: 'datetime')]
|
||||
private ?\DateTime $createdAt = null;
|
||||
#[ORM\Column(name: 'timezone', type: 'string', length: 64, nullable: false)]
|
||||
private ?string $timezone = null;
|
||||
#[ORM\Column(name: 'total', type: 'float', nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'total_rate', type: 'float')]
|
||||
private float $total = 0.00;
|
||||
#[ORM\Column(name: 'tax', type: 'float', nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'invoice.tax', type: 'float')]
|
||||
private float $tax = 0.00;
|
||||
#[ORM\Column(name: 'currency', type: 'string', length: 3, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Assert\Length(max: 3)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'currency', type: 'string')]
|
||||
private ?string $currency = null;
|
||||
#[ORM\Column(name: 'due_days', type: 'integer', length: 3, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Assert\Range(min: 0, max: 999)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Invoice'])]
|
||||
#[Exporter\Expose(label: 'due_days', type: 'integer')]
|
||||
private int $dueDays = 30;
|
||||
#[ORM\Column(name: 'vat', type: 'float', nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Assert\Range(min: 0.0, max: 99.99)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'tax_rate', type: 'float')]
|
||||
private float $vat = 0.00;
|
||||
#[ORM\Column(name: 'status', type: 'string', length: 20, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'status', type: 'string')]
|
||||
private string $status = self::STATUS_NEW;
|
||||
#[ORM\Column(name: 'invoice_filename', type: 'string', length: 150, nullable: false)]
|
||||
@@ -103,6 +126,8 @@ class Invoice implements EntityWithMetaFields
|
||||
private ?string $invoiceFilename = null;
|
||||
private bool $localized = false;
|
||||
#[ORM\Column(name: 'payment_date', type: 'date', nullable: true)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?\DateTime $paymentDate = null;
|
||||
/**
|
||||
* Meta fields registered with the invoice
|
||||
@@ -191,7 +216,6 @@ class Invoice implements EntityWithMetaFields
|
||||
public function setModel(InvoiceModel $model): Invoice
|
||||
{
|
||||
$template = $model->getTemplate();
|
||||
|
||||
if ($template === null) {
|
||||
throw new \InvalidArgumentException('Missing invoice template');
|
||||
}
|
||||
@@ -200,19 +224,25 @@ class Invoice implements EntityWithMetaFields
|
||||
throw new \InvalidArgumentException('Missing due-days or vat setting');
|
||||
}
|
||||
|
||||
$this->customer = $model->getCustomer();
|
||||
$this->user = $model->getUser();
|
||||
$this->total = $model->getCalculator()->getTotal();
|
||||
$this->tax = $model->getCalculator()->getTax();
|
||||
$this->invoiceNumber = $model->getInvoiceNumber();
|
||||
$this->currency = $model->getCurrency();
|
||||
$customer = $model->getCustomer();
|
||||
if ($customer === null) {
|
||||
throw new \InvalidArgumentException('Missing invoice customer');
|
||||
}
|
||||
|
||||
$createdAt = $model->getInvoiceDate();
|
||||
$this->createdAt = \DateTime::createFromInterface($createdAt);
|
||||
$this->timezone = $createdAt->getTimezone()->getName();
|
||||
$user = $model->getUser();
|
||||
if ($user === null) {
|
||||
throw new \InvalidArgumentException('Missing invoice user');
|
||||
}
|
||||
|
||||
$this->dueDays = $template->getDueDays();
|
||||
$this->vat = $template->getVat();
|
||||
$this->setCustomer($customer);
|
||||
$this->setUser($user);
|
||||
$this->setTotal($model->getCalculator()->getTotal());
|
||||
$this->setTax($model->getCalculator()->getTax());
|
||||
$this->setInvoiceNumber($model->getInvoiceNumber());
|
||||
$this->setCurrency($model->getCurrency());
|
||||
$this->setCreatedAt($model->getInvoiceDate());
|
||||
$this->setDueDays($template->getDueDays());
|
||||
$this->setVat($template->getVat());
|
||||
|
||||
return $this;
|
||||
}
|
||||
@@ -380,6 +410,52 @@ class Invoice implements EntityWithMetaFields
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setVat(float $vat): void
|
||||
{
|
||||
$this->vat = $vat;
|
||||
}
|
||||
|
||||
public function setInvoiceNumber(string $invoiceNumber): void
|
||||
{
|
||||
$this->invoiceNumber = $invoiceNumber;
|
||||
}
|
||||
|
||||
public function setCustomer(Customer $customer): void
|
||||
{
|
||||
$this->customer = $customer;
|
||||
}
|
||||
|
||||
public function setUser(User $user): void
|
||||
{
|
||||
$this->user = $user;
|
||||
}
|
||||
|
||||
public function setCreatedAt(\DateTimeInterface $createdAt): void
|
||||
{
|
||||
$this->createdAt = \DateTime::createFromInterface($createdAt);
|
||||
$this->timezone = $createdAt->getTimezone()->getName();
|
||||
}
|
||||
|
||||
public function setTotal(float $total): void
|
||||
{
|
||||
$this->total = $total;
|
||||
}
|
||||
|
||||
public function setTax(float $tax): void
|
||||
{
|
||||
$this->tax = $tax;
|
||||
}
|
||||
|
||||
public function setCurrency(string $currency): void
|
||||
{
|
||||
$this->currency = $currency;
|
||||
}
|
||||
|
||||
public function setDueDays(int $dueDays): void
|
||||
{
|
||||
$this->dueDays = $dueDays;
|
||||
}
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
if ($this->id) {
|
||||
|
||||
@@ -19,9 +19,9 @@ use App\Repository\Query\InvoiceQuery;
|
||||
final class InvoiceModelFactory
|
||||
{
|
||||
public function __construct(
|
||||
private CustomerStatisticService $customerStatisticService,
|
||||
private ProjectStatisticService $projectStatisticService,
|
||||
private ActivityStatisticService $activityStatisticService
|
||||
private readonly CustomerStatisticService $customerStatisticService,
|
||||
private readonly ProjectStatisticService $projectStatisticService,
|
||||
private readonly ActivityStatisticService $activityStatisticService
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user