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:
2026-03-04 00:27:15 +01:00
commit a4a14e441b
86 changed files with 7059 additions and 0 deletions

View File

@@ -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,
) {}
}

View File

@@ -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);
}
}

View 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,
) {}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Application\Port;
interface InvoiceNumberGenerator
{
public function next(): string;
}

View 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;
}

View 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,
) {}
}

View File

@@ -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);
}
}

View 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,
) {}
}

View 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,
) {}
}

View 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;
}
}

View 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;
}
}

View 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));
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace MiniShop\Invoicing\Domain\Model;
enum InvoiceStatus: string
{
case Issued = 'issued';
case Sent = 'sent';
}

View 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');
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View 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(),
),
]);
}
}