refactor: ♻️ update coupon Entity and related VO

This commit is contained in:
Alexis Gousseau 2026-10-12 11:49:03 +02:00
parent 4d3c778d17
commit fdbde84728
No known key found for this signature in database
GPG key ID: 7F3C786EE12DBB72
37 changed files with 366 additions and 324 deletions

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Application\UseCases\Brand\Create;
use Domain\Exceptions\BrandCodeAlreadyExistsException;
use Domain\Models\Entities\Brand;
use Domain\Ports\Repositories\BrandRepository;
use Domain\Ports\Services\PictureStorage;
@ -28,7 +27,7 @@ final readonly class CreateBrand implements UseCase, Transactional
Assert::isInstanceOf($input, Input::class);
if ($this->repository->existsByCode($input->code)) {
throw new BrandCodeAlreadyExistsException($input->code);
throw new \DomainException(sprintf('Brand code "%s" already exists.', $input->code->value));
}
$logoPath = $this->pictureStorage->store($input->logo);

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Application\UseCases\CampaignRequest\Create;
use Domain\Exceptions\BrandNotFoundException;
use Domain\Models\Entities\CampaignRequest;
use Domain\Ports\Repositories\BrandRepository;
use Domain\Ports\Repositories\CampaignRequestRepository;
@ -27,8 +26,8 @@ final readonly class CreateCampaignRequest implements UseCase, Transactional
{
Assert::isInstanceOf($input, Input::class);
if (!$this->brandRepository->existsByCode($input->brandCode)) {
throw new BrandNotFoundException($input->brandCode);
if (! $this->brandRepository->existsByCode($input->brandCode)) {
throw new \DomainException(sprintf('Brand with code "%s" was not found.', $input->brandCode->value));
}
$campaignRequest = CampaignRequest::create(

View file

@ -7,7 +7,6 @@ namespace Application\UseCases\CampaignRequest\Find;
use Application\UseCases\CampaignRequest\Find\Output\CampaignRequestFound;
use Application\UseCases\CampaignRequest\Find\Output\CampaignRequestNotFound;
use Application\UseCases\CampaignRequest\Find\Output\Output;
use Domain\Exceptions\BrandNotFoundException;
use Domain\Models\Entities\Brand;
use Domain\Models\Entities\CampaignRequest;
use Domain\Ports\Repositories\BrandRepository;
@ -39,7 +38,7 @@ final readonly class FindCampaignRequest implements UseCase
$brand = $this->brandRepository->findByCode($campaignRequest->brandCode);
if (! $brand instanceof Brand) {
throw new BrandNotFoundException($campaignRequest->brandCode);
throw new \DomainException(sprintf('Brand with code "%s" was not found.', $campaignRequest->brandCode->value));
}
return new CampaignRequestFound(

View file

@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
namespace Domain\Enums;
enum BarcodeFormat: string
{
case EAN13 = 'ean';
case CODE128 = 'code128';
case GS1_128 = 'gs1';
}

View file

@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace Domain\Exceptions;
use Domain\Models\ValueObjects\Brand\BrandCode;
/**
* There cannot be two brands with the same code.
*/
final class BrandCodeAlreadyExistsException extends \DomainException
{
public function __construct(BrandCode $code)
{
parent::__construct(sprintf('Brand code "%s" already exists.', $code->value));
}
}

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Domain\Exceptions;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Shared\DDD\Exceptions\NotFoundException;
/**
* The brand referenced by a code could not be found.
*/
final class BrandNotFoundException extends NotFoundException
{
public function __construct(BrandCode $brandCode)
{
parent::__construct(sprintf('Brand with code "%s" was not found.', $brandCode->value));
}
}

View file

@ -7,7 +7,7 @@ namespace Domain\Models\Entities;
use Domain\Models\ValueObjects\Actor\ActorId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\Campaign\CampaignCoupon;
use Domain\Models\ValueObjects\Campaign\CampaignCouponArray;
use Domain\Models\ValueObjects\Campaign\CampaignId;
use Domain\Models\ValueObjects\Campaign\CampaignName;
use Shared\DDD\Models\AggregateRoot;
@ -18,25 +18,17 @@ use Shared\DDD\Models\HasTimestampIdentifier;
*/
final readonly class Campaign implements AggregateRoot, HasTimestampIdentifier
{
/**
* @param array<CampaignCoupon> $coupons
*/
private function __construct(
public CampaignId $id,
public CampaignCode $code,
public CampaignName $name,
public bool $publication,
public bool $printAllAtOnce,
public array $coupons,
public CampaignCouponArray $coupons,
public BrandCode $brandCode,
public ActorId $actorId,
public ?\DateTimeInterface $updatedAt = null,
) {
foreach ($this->coupons as $coupon) {
if (!$coupon instanceof CampaignCoupon) {
throw new \DomainException('Campaign coupons must be CampaignCoupon instances.');
}
}
}
public function id(): CampaignId
@ -54,15 +46,12 @@ final readonly class Campaign implements AggregateRoot, HasTimestampIdentifier
return $this->updatedAt;
}
/**
* @param array<CampaignCoupon> $coupons
*/
public static function create(
CampaignCode $code,
CampaignName $name,
bool $publication,
bool $printAllAtOnce,
array $coupons,
CampaignCouponArray $coupons,
BrandCode $brandCode,
ActorId $actorId,
?\DateTimeInterface $updatedAt = null,
@ -80,16 +69,13 @@ final readonly class Campaign implements AggregateRoot, HasTimestampIdentifier
);
}
/**
* @param array<CampaignCoupon> $coupons
*/
public static function hydrate(
CampaignId $id,
CampaignCode $code,
CampaignName $name,
bool $publication,
bool $printAllAtOnce,
array $coupons,
CampaignCouponArray $coupons,
BrandCode $brandCode,
ActorId $actorId,
?\DateTimeInterface $updatedAt = null,

View file

@ -72,7 +72,6 @@ final readonly class CampaignRequest implements AggregateRoot, HasTimestampIdent
campaignType: $campaignType,
requestedCoupon: $requestedCoupon,
status: CampaignRequestStatus::CREATED,
rejectionReason: null,
updatedAt: $updatedAt,
);
}

View file

@ -10,15 +10,15 @@ use Domain\Models\ValueObjects\Coupon\CouponAvailabilityCalendar;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Coupon\CouponDiffusion;
use Domain\Models\ValueObjects\Coupon\CouponId;
use Domain\Models\ValueObjects\Coupon\CouponLegalInformation;
use Domain\Models\ValueObjects\Coupon\CouponValidity;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\CouponName;
use Domain\Models\ValueObjects\CouponPicture;
use Domain\Models\ValueObjects\GS1;
use Domain\Models\ValueObjects\IncoCode;
use Domain\Models\ValueObjects\Coupon\CouponLegalInformationArray;
use Domain\Models\ValueObjects\Coupon\CouponMovementArray;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\Coupon\CouponProcessingCodeArray;
use Domain\Models\ValueObjects\Coupon\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponQuantity;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\ProcessingCode;
use Shared\DDD\Models\AggregateRoot;
use Shared\DDD\Models\HasTimestampIdentifier;
@ -27,11 +27,6 @@ use Shared\DDD\Models\HasTimestampIdentifier;
*/
final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
{
/**
* @param array<CouponValidityInformation> $couponValidityInformation
* @param array<CouponLegalInformation> $couponLegalInformation
* @param array<IncoCode> $products
*/
private function __construct(
public CouponId $id,
public CouponCode $code,
@ -39,28 +34,17 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
public MoneyAmount $amount,
public CouponPicture $picture,
public CouponDiffusion $diffusion,
public CouponValidity $validity,
public array $couponValidityInformation,
public array $couponLegalInformation,
public ProcessingCode $processingCode,
public CouponValidityInformationArray $validityInformations,
public CouponLegalInformationArray $legalInformations,
public CouponProcessingCodeArray $processingCode,
public BrandCode $brandCode,
public ActorId $createdBy,
public array $products = [],
public CouponProductArray $products,
public CouponMovementArray $movements,
public CouponQuantity $quantity,
public ?CouponAvailabilityCalendar $availabilityCalendar = null,
public ?GS1 $gs1 = null,
public ?\DateTimeInterface $updatedAt = null,
) {
foreach ($this->couponValidityInformation as $validityInformation) {
if (!$validityInformation instanceof CouponValidityInformation) {
throw new \DomainException('Coupon validity information must be CouponValidityInformation instances.');
}
}
foreach ($this->couponLegalInformation as $legalInformation) {
if (!$legalInformation instanceof CouponLegalInformation) {
throw new \DomainException('Coupon legal information must be CouponLegalInformation instances.');
}
}
}
public function id(): CouponId
@ -78,26 +62,21 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
return $this->updatedAt;
}
/**
* @param array<CouponValidityInformation> $couponValidityInformation
* @param array<CouponLegalInformation> $couponLegalInformation
* @param array<IncoCode> $products
*/
public static function create(
CouponCode $code,
CouponName $name,
MoneyAmount $amount,
CouponPicture $picture,
CouponDiffusion $diffusion,
CouponValidity $validity,
array $couponValidityInformation,
array $couponLegalInformation,
ProcessingCode $processingCode,
CouponValidityInformationArray $validityInformations,
CouponLegalInformationArray $legalInformations,
CouponProcessingCodeArray $processingCode,
BrandCode $brandCode,
ActorId $createdBy,
array $products = [],
CouponProductArray $products,
CouponMovementArray $movements,
CouponQuantity $quantity,
?CouponAvailabilityCalendar $availabilityCalendar = null,
?GS1 $gs1 = null,
?\DateTimeInterface $updatedAt = null,
): Coupon {
return new self(
@ -107,24 +86,19 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
amount: $amount,
picture: $picture,
diffusion: $diffusion,
validity: $validity,
couponValidityInformation: $couponValidityInformation,
couponLegalInformation: $couponLegalInformation,
validityInformations: $validityInformations,
legalInformations: $legalInformations,
processingCode: $processingCode,
brandCode: $brandCode,
createdBy: $createdBy,
products: $products,
movements: $movements,
quantity: $quantity,
availabilityCalendar: $availabilityCalendar,
gs1: $gs1,
updatedAt: $updatedAt,
);
}
/**
* @param array<CouponValidityInformation> $couponValidityInformation
* @param array<CouponLegalInformation> $couponLegalInformation
* @param array<IncoCode> $products
*/
public static function hydrate(
CouponId $id,
CouponCode $code,
@ -132,15 +106,15 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
MoneyAmount $amount,
CouponPicture $picture,
CouponDiffusion $diffusion,
CouponValidity $validity,
array $couponValidityInformation,
array $couponLegalInformation,
ProcessingCode $processingCode,
CouponValidityInformationArray $validityInformations,
CouponLegalInformationArray $legalInformations,
CouponProcessingCodeArray $processingCode,
BrandCode $brandCode,
ActorId $createdBy,
array $products = [],
CouponProductArray $products,
CouponMovementArray $movements,
CouponQuantity $quantity,
?CouponAvailabilityCalendar $availabilityCalendar = null,
?GS1 $gs1 = null,
?\DateTimeInterface $updatedAt = null,
): self {
return new self(
@ -150,15 +124,15 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
amount: $amount,
picture: $picture,
diffusion: $diffusion,
validity: $validity,
couponValidityInformation: $couponValidityInformation,
couponLegalInformation: $couponLegalInformation,
validityInformations: $validityInformations,
legalInformations: $legalInformations,
processingCode: $processingCode,
brandCode: $brandCode,
createdBy: $createdBy,
products: $products,
movements: $movements,
quantity: $quantity,
availabilityCalendar: $availabilityCalendar,
gs1: $gs1,
updatedAt: $updatedAt,
);
}

View file

@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Domain\Models\Entities;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\ProcessingCode;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Stock\StockAdjustment;
use Domain\Models\ValueObjects\Stock\StockAmount;
use Domain\Models\ValueObjects\Stock\StockId;
@ -21,7 +21,7 @@ final readonly class Stock implements AggregateRoot, HasTimestampIdentifier
public StockId $id,
public StockAmount $stockAmount,
public CampaignCode $campaignCode,
public ProcessingCode $processingCode,
public CouponCode $couponCode,
/** @var array<int, StockAdjustment> $stockAdjustment */
public array $stockAdjustment,
public ?\DateTimeInterface $updatedAt = null,
@ -55,7 +55,7 @@ final readonly class Stock implements AggregateRoot, HasTimestampIdentifier
StockId $id,
StockAmount $stockAmount,
CampaignCode $campaignCode,
ProcessingCode $processingCode,
CouponCode $couponCode,
array $stockAdjustment = [],
?\DateTimeInterface $updatedAt = null,
): self {
@ -63,7 +63,7 @@ final readonly class Stock implements AggregateRoot, HasTimestampIdentifier
id: $id,
stockAmount: $stockAmount,
campaignCode: $campaignCode,
processingCode: $processingCode,
couponCode: $couponCode,
stockAdjustment: $stockAdjustment,
updatedAt: $updatedAt,
);

View file

@ -4,17 +4,13 @@ declare(strict_types=1);
namespace Domain\Models\ValueObjects\Campaign;
use Domain\Models\ValueObjects\Coupon\CouponQuantity;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\PrintRules;
use Domain\Models\ValueObjects\ProcessingCode;
use Shared\Enums\Country;
final readonly class CampaignCoupon
{
public function __construct(
public ProcessingCode $processingCode,
public CouponQuantity $quantity,
public Country $availableIn,
public CouponCode $couponCode,
public ?PrintRules $printRules = null,
) {
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Campaign;
final readonly class CampaignCouponArray
{
public function __construct(
/** @var CampaignCoupon[] */
public array $value,
) {
if ($this->value === []) {
throw new \DomainException('Coupons requires at least one CampaignCoupon.');
}
foreach ($this->value as $coupon) {
if (! $coupon instanceof CampaignCoupon) {
throw new \DomainException('Coupons must be CampaignCoupon instances.');
}
}
}
}

View file

@ -12,7 +12,7 @@ final readonly class CampaignRequestFilters
public ?BrandCode $brandCode = null,
public ?CampaignRequestStatus $status = null,
) {
if (null === $this->brandCode && null === $this->status) {
if (!$this->brandCode instanceof BrandCode && !$this->status instanceof CampaignRequestStatus) {
throw new \InvalidArgumentException('CampaignRequestFilters require at least a brand code or a status.');
}
}

View file

@ -19,7 +19,7 @@ final readonly class CouponAvailabilityCalendar implements Equatable
}
foreach ($this->periods as $period) {
if (!$period instanceof Period) {
if (! $period instanceof Period) {
throw new \DomainException('CouponAvailabilityCalendar periods must be Period instances.');
}
}
@ -27,23 +27,17 @@ final readonly class CouponAvailabilityCalendar implements Equatable
public function isActiveAt(DateTime $in): bool
{
foreach ($this->periods as $period) {
if ($in >= $period->start && $in <= $period->end) {
return true;
}
}
return false;
return array_any($this->periods, fn ($period): bool => $in >= $period->start && $in <= $period->end);
}
public function beginsAt(): DateTime
{
return min(array_map(fn (Period $period) => $period->start, $this->periods));
return min(array_map(fn (Period $period): DateTime => $period->start, $this->periods));
}
public function endsAt(): DateTime
{
return max(array_map(fn (Period $period) => $period->end, $this->periods));
return max(array_map(fn (Period $period): DateTime => $period->end, $this->periods));
}
#[\Override]

View file

@ -2,11 +2,9 @@
declare(strict_types=1);
namespace Domain\Models\ValueObjects;
namespace Domain\Models\ValueObjects\Coupon;
use Shared\DDD\Models\Equatable;
final readonly class IncoCode implements Equatable
final readonly class CouponIncoCode
{
/**
* @throws \DomainException
@ -18,10 +16,4 @@ final readonly class IncoCode implements Equatable
throw new \DomainException('Inco Code must be a positive integer.');
}
}
#[\Override]
public function equals(mixed $other): bool
{
return $other instanceof self && $this->value === $other->value;
}
}

View file

@ -10,7 +10,7 @@ final readonly class CouponLegalInformation
{
public function __construct(
public string $legalText,
public Country $forLocale
public Country $forLocale,
) {
if (mb_trim($this->legalText) === '') {
throw new \DomainException('Legal text cannot be empty.');

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
use Shared\Enums\Country;
final readonly class CouponLegalInformationArray
{
public function __construct(
/** @var CouponLegalInformation[] */
public array $value,
) {
if ($this->value === []) {
throw new \DomainException('CouponLegalInformations requires at least one CouponLegalInformation.');
}
$hasFrance = false;
foreach ($this->value as $couponLegalInformation) {
if (! $couponLegalInformation instanceof CouponLegalInformation) {
throw new \DomainException('Value of CouponLegalInformationArray must be CouponLegalInformation instances.');
}
if ($couponLegalInformation->forLocale === Country::FRANCE) {
$hasFrance = true;
}
}
if (! $hasFrance) {
throw new \DomainException('CouponLegalInformations requires a CouponLegalInformation for France.');
}
}
}

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
final readonly class CouponMovement
{
public \DateTimeInterface $createdAt;
public function __construct(
public int $quantity,
?\DateTimeInterface $createdAt = null,
) {
$this->createdAt = $createdAt ?? new \DateTimeImmutable();
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
final readonly class CouponMovementArray
{
public function __construct(
/** @var CouponMovement[] */
public array $couponMovements,
) {
if ($this->couponMovements === []) {
throw new \DomainException('CouponMovementArray requires at least one CouponMovement.');
}
foreach ($this->couponMovements as $couponMovement) {
if (! $couponMovement instanceof CouponMovement) {
throw new \DomainException('Value of CouponMovementArray must be CouponMovement instances.');
}
}
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
final readonly class CouponName
{
public function __construct(
public string $value,
) {
$trimmed = mb_trim($value);
if ($trimmed === '') {
throw new \DomainException('Coupon name cannot be empty.');
}
if (mb_strlen($trimmed) > 200) {
throw new \DomainException('Coupon name exceeds the maximum allowed length.');
}
}
}

View file

@ -2,14 +2,16 @@
declare(strict_types=1);
namespace Domain\Models\ValueObjects;
namespace Domain\Models\ValueObjects\Coupon;
use Domain\Models\ValueObjects\PicturePath;
final readonly class CouponPicture
{
public function __construct(
public PicturePath $decoration,
public PicturePath $thumbnail,
public PicturePath $inactiveThumbnail
public PicturePath $mobileThumbnail,
) {
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
use Shared\DDD\Models\Equatable;
use Shared\Enums\Country;
final readonly class CouponProcessingCode implements \Stringable, Equatable
{
private const string PATTERN = '/^[A-Za-z0-9]{13}\z/';
public function __construct(
public string $value,
public Country $availableIn,
) {
if (preg_match(self::PATTERN, $this->value) !== 1) {
throw new \DomainException(
'Processing Code must be exactly 13 alphanumeric characters, e.g. ABC1234567890.',
);
}
}
#[\Override]
public function equals(mixed $other): bool
{
return $other instanceof self && $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
use Shared\Enums\Country;
final readonly class CouponProcessingCodeArray
{
public function __construct(
/** @var CouponProcessingCode[] */
public array $value,
) {
if ($this->value === []) {
throw new \DomainException('ProcessingCodes requires at least one CouponProcessingCode.');
}
$hasFrance = false;
foreach ($this->value as $processingCode) {
if (! $processingCode instanceof CouponProcessingCode) {
throw new \DomainException('Value of CouponProcessingCodeArray must be CouponProcessingCode instances.');
}
if ($processingCode->availableIn === Country::FRANCE) {
$hasFrance = true;
}
}
if (! $hasFrance) {
throw new \DomainException('CouponProcessingCodes requires a CouponProcessingCode for France.');
}
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
final readonly class CouponProductArray
{
public function __construct(
/** @var CouponIncoCode[] */
public array $value = [],
) {
foreach ($this->value as $product) {
if (! $product instanceof CouponIncoCode) {
throw new \DomainException('Value of CouponProductArray must be CouponIncoCode instances.');
}
}
}
}

View file

@ -7,9 +7,9 @@ namespace Domain\Models\ValueObjects\Coupon;
final readonly class CouponQuantity
{
public function __construct(
public int $quantity,
public int $value,
) {
if ($this->quantity <= 0) {
if ($this->value <= 0) {
throw new \DomainException('CouponQuantity quantity must be positive.');
}
}

View file

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
use Shared\DDD\Models\Equatable;
use DateTime;
final readonly class CouponValidity implements Equatable
{
public function __construct(
public int $days,
public DateTime $until
) {
if ($this->days <= 0) {
throw new \DomainException('Coupon Validity days must be positive.');
}
}
#[\Override]
public function equals(mixed $other): bool
{
return $other instanceof self && $this->days === $other->days && $this->until == $other->until;
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
use Shared\Enums\Country;
final readonly class CouponValidityInformationArray
{
public function __construct(
/** @var CouponValidityInformation[] */
public array $value,
) {
if ($this->value === []) {
throw new \DomainException('CouponValidityInformations requires at least one CouponValidityInformation.');
}
$hasFrance = false;
foreach ($this->value as $couponLegalInformation) {
if (! $couponLegalInformation instanceof CouponValidityInformation) {
throw new \DomainException('Value of CouponValidityInformationArray must be CouponValidityInformation instances.');
}
if ($couponLegalInformation->forLocale === Country::FRANCE) {
$hasFrance = true;
}
}
if (! $hasFrance) {
throw new \DomainException('CouponValidityInformations requires a CouponValidityInformation for France.');
}
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects;
final readonly class CouponName
{
public function __construct(
public string $value,
) {
if (mb_trim($this->value) === '') {
throw new \DomainException('Coupon name cannot be empty.');
}
}
}

View file

@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use Domain\Enums\BarcodeFormat;
use Shared\DDD\Models\Equatable;
final readonly class GS1 implements \Stringable, Equatable
{
public function __construct(
public string $value,
public BarcodeFormat $format
) {
if (mb_trim($this->value) === '') {
throw new \DomainException('GS1 value cannot be empty.');
}
}
#[\Override]
public function equals(mixed $other): bool
{
return $other instanceof self && $this->value === $other->value && $this->format === $other->format;
}
public function __toString(): string
{
return $this->value;
}
}

View file

@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use Shared\DDD\Models\Equatable;
final readonly class ProcessingCode implements \Stringable, Equatable
{
public function __construct(
public string $value,
) {
if (mb_trim($this->value) === '') {
throw new \DomainException('Processing Code value cannot be empty.');
}
}
#[\Override]
public function equals(mixed $other): bool
{
return $other instanceof self && $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}

View file

@ -4,58 +4,67 @@ declare(strict_types=1);
namespace Domain\Models\ValueObjects\RequestedCoupon;
use Domain\Models\ValueObjects\Coupon\CouponValidity;
use Domain\Models\ValueObjects\Coupon\CouponLegalInformation;
use Domain\Models\ValueObjects\Coupon\CouponLegalInformationArray;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\Coupon\CouponProcessingCode;
use Domain\Models\ValueObjects\Coupon\CouponProcessingCodeArray;
use Domain\Models\ValueObjects\Coupon\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\CouponName;
use Domain\Models\ValueObjects\GS1;
use Domain\Models\ValueObjects\IncoCode;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\Period;
use Domain\Models\ValueObjects\PrintRules;
use Domain\Models\ValueObjects\ProcessingCode;
use Shared\Enums\Country;
final readonly class RequestedCoupon
{
/**
* @param array<CouponValidityInformation> $couponValidityInformation
* @param array<IncoCode> $products
*/
public function __construct(
public CouponName $name,
public CouponName $couponName,
public RequestedCouponQuantity $quantity,
public Period $diffusion,
public MoneyAmount $moneyAmount,
public ProcessingCode $processingCode,
public CouponValidity $couponValidity,
public array $couponValidityInformation,
public array $products = [],
public CouponPicture $couponPicture,
public CouponProcessingCodeArray $couponProcessingCodes,
public CouponValidityInformationArray $couponValidityInformations,
public CouponLegalInformationArray $couponLegalInformations,
public CouponProductArray $couponProducts,
public ?PrintRules $printRules = null,
public ?GS1 $gs1 = null,
) {
foreach ($this->couponValidityInformation as $validityInformation) {
if (!$validityInformation instanceof CouponValidityInformation) {
throw new \DomainException('Coupon validity information must be CouponValidityInformation instances.');
}
}
if (!$this->hasValidityInformationFor(Country::FRANCE)) {
throw new \DomainException('A coupon requires validity information for France.');
}
if ($this->gs1 instanceof GS1 && !$this->hasValidityInformationFor(Country::BENELUX)) {
throw new \DomainException('A GS1 coupon requires validity information for Benelux.');
if ($this->hasProcessingCodeFor(Country::BENELUX)) {
$this->assertCountryIsFullyCovered(Country::BENELUX);
}
}
private function hasValidityInformationFor(Country $country): bool
private function assertCountryIsFullyCovered(Country $country): void
{
foreach ($this->couponValidityInformation as $validityInformation) {
if ($validityInformation->forLocale === $country) {
return true;
}
if (! $this->hasProcessingCodeFor($country)) {
throw new \DomainException(sprintf('A coupon requires a processing code for %s.', $country->name));
}
return false;
if (! $this->hasInformationFor($country, $this->couponValidityInformations)) {
throw new \DomainException(sprintf('A coupon requires validity information for %s.', $country->name));
}
if (! $this->hasInformationFor($country, $this->couponLegalInformations)) {
throw new \DomainException(sprintf('A coupon requires legal information for %s.', $country->name));
}
}
private function hasProcessingCodeFor(Country $country): bool
{
return array_any(
$this->couponProcessingCodes->value,
fn (CouponProcessingCode $processingCode): bool => $processingCode->availableIn === $country,
);
}
private function hasInformationFor(Country $country, CouponValidityInformationArray | CouponLegalInformationArray $informations): bool
{
return array_any(
$informations->value,
fn (CouponValidityInformation | CouponLegalInformation $information): bool => $information->forLocale === $country,
);
}
}

View file

@ -36,10 +36,10 @@ final class DatesHelper
$now = self::now();
return match (true) {
$from !== null && $to !== null => $now->between($from, $to),
$from !== null => $now->greaterThanOrEqualTo($from),
$to !== null => $now->lessThanOrEqualTo($to),
default => false,
$from instanceof CarbonInterface && $to instanceof CarbonInterface => $now->between($from, $to),
$from instanceof CarbonInterface => $now->greaterThanOrEqualTo($from),
$to instanceof CarbonInterface => $now->lessThanOrEqualTo($to),
default => false,
};
}
@ -87,7 +87,7 @@ final class DatesHelper
break;
default:
try {
$output = (new \DateTimeImmutable($datetime, new \DateTimeZone('UTC')))->setTimezone(new \DateTimeZone('UTC'));
$output = new \DateTimeImmutable($datetime, new \DateTimeZone('UTC'))->setTimezone(new \DateTimeZone('UTC'));
} catch (\Exception) {
throw InvalidDateFormatException::forDateString($datetime);
}
@ -96,21 +96,21 @@ final class DatesHelper
return $output;
}
protected static function parseFrench(string $datetime): \DateTimeInterface
private static function parseFrench(string $datetime): \DateTimeInterface
{
$dt = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $datetime . ' 00:00:00', new \DateTimeZone('UTC'));
return $dt ?: self::parseFallback($datetime);
}
protected static function parseFrenchInverted(string $datetime): \DateTimeInterface
private static function parseFrenchInverted(string $datetime): \DateTimeInterface
{
$dt = \DateTimeImmutable::createFromFormat('d-m-Y H:i:s', $datetime . ' 00:00:00', new \DateTimeZone('UTC'));
return $dt ?: self::parseFallback($datetime);
}
protected static function parseRfc3339(string $datetime): \DateTimeInterface
private static function parseRfc3339(string $datetime): \DateTimeInterface
{
$dt = \DateTimeImmutable::createFromFormat(\DateTimeInterface::RFC3339, $datetime);
if ($dt !== false) {
@ -122,17 +122,17 @@ final class DatesHelper
return $dt ? $dt->setTimezone(new \DateTimeZone('UTC')) : self::parseFallback($datetime);
}
protected static function parseIso8601(string $datetime): \DateTimeInterface
private static function parseIso8601(string $datetime): \DateTimeInterface
{
$dt = \DateTimeImmutable::createFromFormat(\DateTimeInterface::ATOM, $datetime);
return $dt ? $dt->setTimezone(new \DateTimeZone('UTC')) : self::parseFallback($datetime);
}
protected static function parseFallback(string $datetime): \DateTimeInterface
private static function parseFallback(string $datetime): \DateTimeInterface
{
try {
return (new \DateTimeImmutable($datetime, new \DateTimeZone('UTC')))->setTimezone(new \DateTimeZone('UTC'));
return new \DateTimeImmutable($datetime, new \DateTimeZone('UTC'))->setTimezone(new \DateTimeZone('UTC'));
} catch (\Throwable) {
throw InvalidDateFormatException::forDateString($datetime);
}

View file

@ -9,7 +9,7 @@ final readonly class Paginated
{
/** @param T[] $items */
public function __construct(
private readonly Pagination $pagination,
private Pagination $pagination,
private array $items,
private int $total
) {

View file

@ -9,7 +9,7 @@ use Shared\DDD\Models\Equatable;
/**
* Immutable value object representing a pagination range.
*/
final readonly class Pagination implements Equatable
final readonly class Pagination implements Equatable, \Stringable
{
/** ERR_TOO_MANY_RECORDS fixed to 10k */
public const int MAX_PAGE_SIZE = PageSize::MAX_PAGE_SIZE;

View file

@ -64,7 +64,7 @@ describe('reject campaign request use case', function (): void {
it('rejects a campaign request and persists it', function (): void {
// GIVEN
CarbonImmutable::setTestNow('2026-06-15 12:00:00');
CarbonImmutable::setTestNow('2026-06-15 12:00:00 UTC');
$id = CampaignRequestId::generate();
$campaignRequest = CampaignRequest::hydrate(
$id,

View file

@ -119,7 +119,7 @@ describe('brand', function (): void {
it('updates a brand logo and sets updatedAt timestamp', function (): void {
// GIVEN
CarbonImmutable::setTestNow('2026-06-15 12:00:00');
CarbonImmutable::setTestNow('2026-06-15 12:00:00 UTC');
$id = BrandId::generate();
$logoOld = new PicturePath('files/picture-old.jpg');
$logoNew = new PicturePath('files/picture-new.jpg');
@ -148,7 +148,7 @@ describe('brand', function (): void {
it('updates a brand rcs and sets updatedAt timestamp', function (): void {
// GIVEN
CarbonImmutable::setTestNow('2026-06-15 14:30:00');
CarbonImmutable::setTestNow('2026-06-15 14:30:00 UTC');
$id = BrandId::generate();
$rcsOld = new Rcs('RCS PARIS 111 222 333');
$rcsNew = new Rcs('RCS NANTES 444 555 666');
@ -177,7 +177,7 @@ describe('brand', function (): void {
it('updates a brand contact and sets updatedAt timestamp', function (): void {
// GIVEN
CarbonImmutable::setTestNow('2026-06-15 16:45:00');
CarbonImmutable::setTestNow('2026-06-15 16:45:00 UTC');
$id = BrandId::generate();
$contactOld = new Email('contact-old@example.com');
$contactNew = new Email('contact-new@example.com');

View file

@ -174,7 +174,7 @@ describe('campaign request', function (): void {
it('rejects a campaign request and sets the rejection reason and updatedAt', function (): void {
// GIVEN
CarbonImmutable::setTestNow('2026-06-15 12:00:00');
CarbonImmutable::setTestNow('2026-06-15 12:00:00 UTC');
$campaignRequest = CampaignRequest::hydrate(
CampaignRequestId::generate(),
$this->code,