Step 00 — Squelette + intégration naïve
3 Bounded Contexts (Sales, Invoicing, LegacyFulfillment) avec : - Domaines complets (agrégats, VOs, événements, invariants) - Couche application (commands, queries, ports) - Infrastructure in-memory (repos, gateway fake) - Controllers HTTP Symfony - Couplage naïf synchrone entre BC via NaiveSalesEventPublisher - 20 tests unitaires et d'intégration passants
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiniShop\Invoicing\Application\Command;
|
||||
|
||||
final readonly class IssueInvoiceForExternalOrder
|
||||
{
|
||||
/**
|
||||
* @param list<array{description: string, quantity: int, unitPriceInCents: int, currency: string}> $lines
|
||||
*/
|
||||
public function __construct(
|
||||
public string $externalOrderId,
|
||||
public string $customerName,
|
||||
public string $customerAddress,
|
||||
public array $lines,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiniShop\Invoicing\Application\Command;
|
||||
|
||||
use MiniShop\Invoicing\Application\Port\InvoiceNumberGenerator;
|
||||
use MiniShop\Invoicing\Application\Port\InvoiceRepository;
|
||||
use MiniShop\Invoicing\Domain\Model\BillingParty;
|
||||
use MiniShop\Invoicing\Domain\Model\Invoice;
|
||||
use MiniShop\Invoicing\Domain\Model\InvoiceId;
|
||||
use MiniShop\Invoicing\Domain\Model\InvoiceLine;
|
||||
use MiniShop\Invoicing\Domain\Model\TaxRate;
|
||||
use MiniShop\Shared\Technical\Clock;
|
||||
use MiniShop\Shared\Technical\UuidGenerator;
|
||||
|
||||
final readonly class IssueInvoiceForExternalOrderHandler
|
||||
{
|
||||
public function __construct(
|
||||
private InvoiceRepository $invoiceRepository,
|
||||
private InvoiceNumberGenerator $invoiceNumberGenerator,
|
||||
private Clock $clock,
|
||||
) {}
|
||||
|
||||
public function __invoke(IssueInvoiceForExternalOrder $command): void
|
||||
{
|
||||
$lines = array_map(
|
||||
static fn (array $line): InvoiceLine => new InvoiceLine(
|
||||
$line['description'],
|
||||
$line['quantity'],
|
||||
$line['unitPriceInCents'],
|
||||
$line['currency'],
|
||||
TaxRate::standard(),
|
||||
),
|
||||
$command->lines,
|
||||
);
|
||||
|
||||
$invoice = Invoice::issueForExternalOrder(
|
||||
InvoiceId::fromString(UuidGenerator::generate()),
|
||||
$this->invoiceNumberGenerator->next(),
|
||||
$command->externalOrderId,
|
||||
new BillingParty($command->customerName, $command->customerAddress),
|
||||
$lines,
|
||||
$this->clock->now(),
|
||||
);
|
||||
|
||||
$this->invoiceRepository->save($invoice);
|
||||
}
|
||||
}
|
||||
12
src/Invoicing/Application/Command/MarkInvoiceAsSent.php
Normal file
12
src/Invoicing/Application/Command/MarkInvoiceAsSent.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiniShop\Invoicing\Application\Command;
|
||||
|
||||
final readonly class MarkInvoiceAsSent
|
||||
{
|
||||
public function __construct(
|
||||
public string $invoiceId,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiniShop\Invoicing\Application\Command;
|
||||
|
||||
use MiniShop\Invoicing\Application\Port\InvoiceRepository;
|
||||
use MiniShop\Invoicing\Domain\Model\InvoiceId;
|
||||
|
||||
final readonly class MarkInvoiceAsSentHandler
|
||||
{
|
||||
public function __construct(
|
||||
private InvoiceRepository $invoiceRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(MarkInvoiceAsSent $command): void
|
||||
{
|
||||
$invoice = $this->invoiceRepository->get(InvoiceId::fromString($command->invoiceId));
|
||||
$invoice->markAsSent();
|
||||
$this->invoiceRepository->save($invoice);
|
||||
}
|
||||
}
|
||||
10
src/Invoicing/Application/Port/InvoiceNumberGenerator.php
Normal file
10
src/Invoicing/Application/Port/InvoiceNumberGenerator.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiniShop\Invoicing\Application\Port;
|
||||
|
||||
interface InvoiceNumberGenerator
|
||||
{
|
||||
public function next(): string;
|
||||
}
|
||||
17
src/Invoicing/Application/Port/InvoiceRepository.php
Normal file
17
src/Invoicing/Application/Port/InvoiceRepository.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiniShop\Invoicing\Application\Port;
|
||||
|
||||
use MiniShop\Invoicing\Domain\Model\Invoice;
|
||||
use MiniShop\Invoicing\Domain\Model\InvoiceId;
|
||||
|
||||
interface InvoiceRepository
|
||||
{
|
||||
public function save(Invoice $invoice): void;
|
||||
|
||||
public function get(InvoiceId $id): Invoice;
|
||||
|
||||
public function findByExternalOrderId(string $externalOrderId): ?Invoice;
|
||||
}
|
||||
12
src/Invoicing/Application/Query/GetInvoiceByOrderRef.php
Normal file
12
src/Invoicing/Application/Query/GetInvoiceByOrderRef.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiniShop\Invoicing\Application\Query;
|
||||
|
||||
final readonly class GetInvoiceByOrderRef
|
||||
{
|
||||
public function __construct(
|
||||
public string $externalOrderId,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiniShop\Invoicing\Application\Query;
|
||||
|
||||
use MiniShop\Invoicing\Application\Port\InvoiceRepository;
|
||||
use MiniShop\Invoicing\Domain\Model\Invoice;
|
||||
|
||||
final readonly class GetInvoiceByOrderRefHandler
|
||||
{
|
||||
public function __construct(
|
||||
private InvoiceRepository $invoiceRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(GetInvoiceByOrderRef $query): ?Invoice
|
||||
{
|
||||
return $this->invoiceRepository->findByExternalOrderId($query->externalOrderId);
|
||||
}
|
||||
}
|
||||
18
src/Invoicing/Domain/Event/InvoiceIssued.php
Normal file
18
src/Invoicing/Domain/Event/InvoiceIssued.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiniShop\Invoicing\Domain\Event;
|
||||
|
||||
use MiniShop\Invoicing\Domain\Model\InvoiceId;
|
||||
|
||||
final readonly class InvoiceIssued
|
||||
{
|
||||
public function __construct(
|
||||
public InvoiceId $invoiceId,
|
||||
public string $invoiceNumber,
|
||||
public string $externalOrderId,
|
||||
public int $totalInclTaxInCents,
|
||||
public \DateTimeImmutable $issuedAt,
|
||||
) {}
|
||||
}
|
||||
13
src/Invoicing/Domain/Model/BillingParty.php
Normal file
13
src/Invoicing/Domain/Model/BillingParty.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiniShop\Invoicing\Domain\Model;
|
||||
|
||||
final readonly class BillingParty
|
||||
{
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public string $address,
|
||||
) {}
|
||||
}
|
||||
111
src/Invoicing/Domain/Model/Invoice.php
Normal file
111
src/Invoicing/Domain/Model/Invoice.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiniShop\Invoicing\Domain\Model;
|
||||
|
||||
use MiniShop\Invoicing\Domain\Event\InvoiceIssued;
|
||||
|
||||
final class Invoice
|
||||
{
|
||||
private InvoiceStatus $status;
|
||||
/** @var list<object> */
|
||||
private array $domainEvents = [];
|
||||
|
||||
/**
|
||||
* @param list<InvoiceLine> $lines
|
||||
*/
|
||||
private function __construct(
|
||||
public readonly InvoiceId $id,
|
||||
public readonly string $invoiceNumber,
|
||||
public readonly string $externalOrderId,
|
||||
public readonly BillingParty $billingParty,
|
||||
private readonly array $lines,
|
||||
private readonly \DateTimeImmutable $issuedAt,
|
||||
) {
|
||||
$this->status = InvoiceStatus::Issued;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<InvoiceLine> $lines
|
||||
*/
|
||||
public static function issueForExternalOrder(
|
||||
InvoiceId $id,
|
||||
string $invoiceNumber,
|
||||
string $externalOrderId,
|
||||
BillingParty $billingParty,
|
||||
array $lines,
|
||||
\DateTimeImmutable $issuedAt,
|
||||
): self {
|
||||
if ($lines === []) {
|
||||
throw new \DomainException('An invoice must have at least one line.');
|
||||
}
|
||||
|
||||
$invoice = new self($id, $invoiceNumber, $externalOrderId, $billingParty, $lines, $issuedAt);
|
||||
|
||||
$invoice->recordEvent(new InvoiceIssued(
|
||||
$id,
|
||||
$invoiceNumber,
|
||||
$externalOrderId,
|
||||
$invoice->totalInclTax(),
|
||||
$issuedAt,
|
||||
));
|
||||
|
||||
return $invoice;
|
||||
}
|
||||
|
||||
public function markAsSent(): void
|
||||
{
|
||||
if ($this->status === InvoiceStatus::Sent) {
|
||||
throw new \DomainException('Invoice is already sent.');
|
||||
}
|
||||
|
||||
$this->status = InvoiceStatus::Sent;
|
||||
}
|
||||
|
||||
public function totalExclTax(): int
|
||||
{
|
||||
return array_sum(array_map(
|
||||
static fn (InvoiceLine $line): int => $line->lineTotalExclTax(),
|
||||
$this->lines,
|
||||
));
|
||||
}
|
||||
|
||||
public function totalInclTax(): int
|
||||
{
|
||||
return array_sum(array_map(
|
||||
static fn (InvoiceLine $line): int => $line->lineTotalInclTax(),
|
||||
$this->lines,
|
||||
));
|
||||
}
|
||||
|
||||
public function status(): InvoiceStatus
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
/** @return list<InvoiceLine> */
|
||||
public function lines(): array
|
||||
{
|
||||
return $this->lines;
|
||||
}
|
||||
|
||||
public function issuedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->issuedAt;
|
||||
}
|
||||
|
||||
/** @return list<object> */
|
||||
public function releaseEvents(): array
|
||||
{
|
||||
$events = $this->domainEvents;
|
||||
$this->domainEvents = [];
|
||||
|
||||
return $events;
|
||||
}
|
||||
|
||||
private function recordEvent(object $event): void
|
||||
{
|
||||
$this->domainEvents[] = $event;
|
||||
}
|
||||
}
|
||||
25
src/Invoicing/Domain/Model/InvoiceId.php
Normal file
25
src/Invoicing/Domain/Model/InvoiceId.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiniShop\Invoicing\Domain\Model;
|
||||
|
||||
final readonly class InvoiceId
|
||||
{
|
||||
private function __construct(public string $value) {}
|
||||
|
||||
public static function fromString(string $value): self
|
||||
{
|
||||
return new self($value);
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return $this->value === $other->value;
|
||||
}
|
||||
}
|
||||
30
src/Invoicing/Domain/Model/InvoiceLine.php
Normal file
30
src/Invoicing/Domain/Model/InvoiceLine.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiniShop\Invoicing\Domain\Model;
|
||||
|
||||
final readonly class InvoiceLine
|
||||
{
|
||||
public function __construct(
|
||||
public string $description,
|
||||
public int $quantity,
|
||||
public int $unitPriceInCents,
|
||||
public string $currency,
|
||||
public TaxRate $taxRate,
|
||||
) {
|
||||
if ($quantity <= 0) {
|
||||
throw new \InvalidArgumentException('Quantity must be positive.');
|
||||
}
|
||||
}
|
||||
|
||||
public function lineTotalExclTax(): int
|
||||
{
|
||||
return $this->quantity * $this->unitPriceInCents;
|
||||
}
|
||||
|
||||
public function lineTotalInclTax(): int
|
||||
{
|
||||
return (int) round($this->lineTotalExclTax() * (1 + $this->taxRate->rate));
|
||||
}
|
||||
}
|
||||
11
src/Invoicing/Domain/Model/InvoiceStatus.php
Normal file
11
src/Invoicing/Domain/Model/InvoiceStatus.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiniShop\Invoicing\Domain\Model;
|
||||
|
||||
enum InvoiceStatus: string
|
||||
{
|
||||
case Issued = 'issued';
|
||||
case Sent = 'sent';
|
||||
}
|
||||
27
src/Invoicing/Domain/Model/TaxRate.php
Normal file
27
src/Invoicing/Domain/Model/TaxRate.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiniShop\Invoicing\Domain\Model;
|
||||
|
||||
final readonly class TaxRate
|
||||
{
|
||||
public function __construct(
|
||||
public float $rate,
|
||||
public string $label,
|
||||
) {
|
||||
if ($rate < 0 || $rate > 1) {
|
||||
throw new \InvalidArgumentException('Tax rate must be between 0 and 1.');
|
||||
}
|
||||
}
|
||||
|
||||
public static function standard(): self
|
||||
{
|
||||
return new self(0.20, 'TVA 20%');
|
||||
}
|
||||
|
||||
public static function zero(): self
|
||||
{
|
||||
return new self(0.0, 'Exonere');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiniShop\Invoicing\Infrastructure\Persistence;
|
||||
|
||||
use MiniShop\Invoicing\Application\Port\InvoiceRepository;
|
||||
use MiniShop\Invoicing\Domain\Model\Invoice;
|
||||
use MiniShop\Invoicing\Domain\Model\InvoiceId;
|
||||
|
||||
final class InMemoryInvoiceRepository implements InvoiceRepository
|
||||
{
|
||||
/** @var array<string, Invoice> */
|
||||
private array $invoices = [];
|
||||
|
||||
public function save(Invoice $invoice): void
|
||||
{
|
||||
$this->invoices[$invoice->id->toString()] = $invoice;
|
||||
}
|
||||
|
||||
public function get(InvoiceId $id): Invoice
|
||||
{
|
||||
return $this->invoices[$id->toString()]
|
||||
?? throw new \RuntimeException(sprintf('Invoice "%s" not found.', $id->toString()));
|
||||
}
|
||||
|
||||
public function findByExternalOrderId(string $externalOrderId): ?Invoice
|
||||
{
|
||||
foreach ($this->invoices as $invoice) {
|
||||
if ($invoice->externalOrderId === $externalOrderId) {
|
||||
return $invoice;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiniShop\Invoicing\Infrastructure;
|
||||
|
||||
use MiniShop\Invoicing\Application\Port\InvoiceNumberGenerator;
|
||||
|
||||
final class SequentialInvoiceNumberGenerator implements InvoiceNumberGenerator
|
||||
{
|
||||
private int $counter = 0;
|
||||
|
||||
public function next(): string
|
||||
{
|
||||
return sprintf('INV-%06d', ++$this->counter);
|
||||
}
|
||||
}
|
||||
52
src/Invoicing/Interfaces/Http/GetInvoiceController.php
Normal file
52
src/Invoicing/Interfaces/Http/GetInvoiceController.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MiniShop\Invoicing\Interfaces\Http;
|
||||
|
||||
use MiniShop\Invoicing\Application\Query\GetInvoiceByOrderRef;
|
||||
use MiniShop\Invoicing\Application\Query\GetInvoiceByOrderRefHandler;
|
||||
use MiniShop\Invoicing\Domain\Model\InvoiceLine;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/invoicing/orders/{externalOrderId}/invoice', name: 'invoicing_get_invoice', methods: ['GET'])]
|
||||
final readonly class GetInvoiceController
|
||||
{
|
||||
public function __construct(
|
||||
private GetInvoiceByOrderRefHandler $handler,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $externalOrderId): JsonResponse
|
||||
{
|
||||
$invoice = ($this->handler)(new GetInvoiceByOrderRef($externalOrderId));
|
||||
|
||||
if ($invoice === null) {
|
||||
return new JsonResponse(['error' => 'Invoice not found.'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'invoiceId' => $invoice->id->toString(),
|
||||
'invoiceNumber' => $invoice->invoiceNumber,
|
||||
'externalOrderId' => $invoice->externalOrderId,
|
||||
'billingParty' => [
|
||||
'name' => $invoice->billingParty->name,
|
||||
'address' => $invoice->billingParty->address,
|
||||
],
|
||||
'status' => $invoice->status()->value,
|
||||
'totalExclTax' => $invoice->totalExclTax(),
|
||||
'totalInclTax' => $invoice->totalInclTax(),
|
||||
'lines' => array_map(
|
||||
static fn (InvoiceLine $line): array => [
|
||||
'description' => $line->description,
|
||||
'quantity' => $line->quantity,
|
||||
'unitPriceInCents' => $line->unitPriceInCents,
|
||||
'lineTotalExclTax' => $line->lineTotalExclTax(),
|
||||
'lineTotalInclTax' => $line->lineTotalInclTax(),
|
||||
],
|
||||
$invoice->lines(),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user