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:
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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user