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