Merge branch 'coupon' into 'refonte'

Coupon

See merge request ldc-web/projets/mesbr!57
This commit is contained in:
Alexis Gousseau 2026-10-16 16:24:12 +02:00
commit 53fb12f20f
162 changed files with 6511 additions and 2198 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);
@ -49,7 +48,6 @@ final readonly class CreateBrand implements UseCase, Transactional
logo: $brand->logo,
rcs: $brand->rcs,
contact: $brand->contact,
createdAt: $brand->createdAt(),
);
}
}

View file

@ -19,7 +19,6 @@ final readonly class Output implements UseCaseOutput
public PicturePath $logo,
public Rcs $rcs,
public Email $contact,
public \DateTimeInterface $createdAt,
) {
}
}

View file

@ -39,6 +39,7 @@ final readonly class FindBrand implements UseCase
rcs: $brand->rcs,
contact: $brand->contact,
createdAt: $brand->createdAt(),
updatedAt: $brand->updatedAt(),
);
}
}

View file

@ -19,6 +19,7 @@ final readonly class BrandFound implements Output
public Rcs $rcs,
public Email $contact,
public \DateTimeInterface $createdAt,
public ?\DateTimeInterface $updatedAt,
) {
}
}

View file

@ -18,8 +18,6 @@ final readonly class BrandUpdated implements Output
public PicturePath $logo,
public Rcs $rcs,
public Email $contact,
public \DateTimeInterface $createdAt,
public ?\DateTimeInterface $updatedAt = null,
) {
}
}

View file

@ -56,8 +56,6 @@ final readonly class UpdateBrand implements UseCase, Transactional
logo: $brand->logo,
rcs: $brand->rcs,
contact: $brand->contact,
createdAt: $brand->createdAt(),
updatedAt: $brand->updatedAt(),
);
}
}

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(
@ -49,7 +48,6 @@ final readonly class CreateCampaignRequest implements UseCase, Transactional
campaignType: $campaignRequest->campaignType,
requestedCoupon: $campaignRequest->requestedCoupon,
status: $campaignRequest->status,
createdAt: $campaignRequest->createdAt(),
);
}
}

View file

@ -23,7 +23,6 @@ final readonly class Output implements UseCaseOutput
public CampaignRequestType $campaignType,
public RequestedCoupon $requestedCoupon,
public CampaignRequestStatus $status,
public \DateTimeInterface $createdAt,
) {
}
}

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

@ -14,7 +14,6 @@ final readonly class CampaignRequestRejected implements Output
public CampaignRequestId $id,
public CampaignRequestStatus $status,
public RejectionReason $rejectionReason,
public ?\DateTimeInterface $updatedAt = null
) {
}
}

View file

@ -41,7 +41,6 @@ final readonly class RejectCampaignRequest implements UseCase, Transactional
id: $campaignRequest->id(),
status: $campaignRequest->status,
rejectionReason:$campaignRequest->rejectionReason(),
updatedAt: $campaignRequest->updatedAt(),
);
}
}

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Coupon\Create;
use Domain\Models\Entities\Coupon;
use Domain\Ports\Repositories\BrandRepository;
use Domain\Ports\Repositories\CouponRepository;
use Shared\DDD\UseCases\Input as BaseInput;
use Shared\DDD\UseCases\UseCase;
use Shared\DDD\UseCases\Transactional;
use Webmozart\Assert\Assert;
/** @implements UseCase<\Application\UseCases\Coupon\Create\Input, \Application\UseCases\Coupon\Create\Output> */
final readonly class CreateCoupon implements UseCase, Transactional
{
public function __construct(
private CouponRepository $couponRepository,
private BrandRepository $brandRepository,
) {
}
#[\Override]
public function execute(BaseInput $input): Output
{
Assert::isInstanceOf($input, Input::class);
if (! $this->brandRepository->existsByCode($input->brandCode)) {
throw new \DomainException(sprintf('Brand code "%s" was not found.', $input->brandCode->value));
}
if ($this->couponRepository->existsByCode($input->code)) {
throw new \DomainException(sprintf('Coupon code "%s" already exists.', $input->code->value));
}
$coupon = Coupon::create(
code: $input->code,
name: $input->name,
amount: $input->amount,
picture: $input->picture,
diffusion: $input->diffusion,
validityInformations: $input->validityInformations,
legalInformations: $input->legalInformations,
processingCode: $input->processingCode,
brandCode: $input->brandCode,
createdBy: $input->createdBy,
products: $input->products,
movements: $input->movements,
availabilityCalendar: $input->availabilityCalendar,
);
$this->couponRepository->save($coupon);
return new Output(
id: $coupon->id,
code: $coupon->code,
name: $coupon->name,
amount: $coupon->amount,
picture: $coupon->picture,
diffusion: $coupon->diffusion,
validityInformations: $coupon->validityInformations,
legalInformations: $coupon->legalInformations,
processingCode: $coupon->processingCode,
brandCode: $coupon->brandCode,
createdBy: $coupon->createdBy,
products: $coupon->products,
movements: $coupon->movements,
availabilityCalendar: $coupon->availabilityCalendar,
);
}
}

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Coupon\Create;
use Domain\Models\ValueObjects\Actor\ActorId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Coupon\CouponAvailabilityCalendar;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Coupon\CouponDiffusion;
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\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\ProcessingCodeArray;
use Shared\DDD\UseCases\Input as UseCaseInput;
final readonly class Input implements UseCaseInput
{
public function __construct(
public CouponCode $code,
public CouponName $name,
public MoneyAmount $amount,
public CouponPicture $picture,
public CouponDiffusion $diffusion,
public CouponValidityInformationArray $validityInformations,
public CouponLegalInformationArray $legalInformations,
public ProcessingCodeArray $processingCode,
public BrandCode $brandCode,
public ActorId $createdBy,
public CouponProductArray $products,
public CouponMovementArray $movements,
public ?CouponAvailabilityCalendar $availabilityCalendar = null,
) {
}
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Coupon\Create;
use Domain\Models\ValueObjects\Actor\ActorId;
use Domain\Models\ValueObjects\Brand\BrandCode;
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\CouponLegalInformationArray;
use Domain\Models\ValueObjects\Coupon\CouponMovementArray;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\Coupon\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\ProcessingCodeArray;
use Shared\DDD\UseCases\Output as UseCaseOutput;
final readonly class Output implements UseCaseOutput
{
public function __construct(
public CouponId $id,
public CouponCode $code,
public CouponName $name,
public MoneyAmount $amount,
public CouponPicture $picture,
public CouponDiffusion $diffusion,
public CouponValidityInformationArray $validityInformations,
public CouponLegalInformationArray $legalInformations,
public ProcessingCodeArray $processingCode,
public BrandCode $brandCode,
public ActorId $createdBy,
public CouponProductArray $products,
public CouponMovementArray $movements,
public ?CouponAvailabilityCalendar $availabilityCalendar = null,
) {
}
}

View file

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Coupon\Find;
use Application\UseCases\Coupon\Find\Output\Output;
use Application\UseCases\Coupon\Find\Output\CouponFound;
use Application\UseCases\Coupon\Find\Output\CouponNotFound;
use Domain\Models\Entities\Coupon;
use Domain\Ports\Repositories\BrandRepository;
use Domain\Ports\Repositories\CampaignRepository;
use Domain\Ports\Repositories\CouponRepository;
use Shared\DDD\UseCases\Input as BaseInput;
use Shared\DDD\UseCases\UseCase;
use Webmozart\Assert\Assert;
/** @implements UseCase<\Application\UseCases\Coupon\Find\Input, \Application\UseCases\Coupon\Find\Output\Output> */
final readonly class FindCoupon implements UseCase
{
public function __construct(
private CouponRepository $couponRepository,
private BrandRepository $brandRepository,
private CampaignRepository $campaignRepository,
) {
}
#[\Override]
public function execute(BaseInput $input): Output
{
Assert::isInstanceOf($input, Input::class);
$coupon = $this->couponRepository->findByCode($input->code);
if (! $coupon instanceof Coupon) {
return new CouponNotFound();
}
if (! $this->brandRepository->existsByCode($coupon->brandCode)) {
throw new \DomainException(sprintf('Brand code "%s" was not found.', $coupon->brandCode->value));
}
$campaigns = $this->campaignRepository->findByCouponCode($coupon->code);
return new CouponFound(
code: $coupon->code,
name: $coupon->name,
amount: $coupon->amount,
picture: $coupon->picture,
diffusion: $coupon->diffusion,
availabilityCalendar: $coupon->availabilityCalendar,
validityInformations: $coupon->validityInformations,
legalInformations: $coupon->legalInformations,
processingCode: $coupon->processingCode,
brandCode: $coupon->brandCode,
createdBy: $coupon->createdBy,
products: $coupon->products,
movements: $coupon->movements,
quantity: $coupon->quantity(),
campaigns: $campaigns,
createdAt: $coupon->createdAt(),
updatedAt: $coupon->updatedAt(),
);
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Coupon\Find;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Shared\DDD\UseCases\Input as UseCaseInput;
final readonly class Input implements UseCaseInput
{
public function __construct(
public CouponCode $code,
) {
}
}

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Coupon\Find\Output;
use Domain\Models\Entities\Campaign;
use Domain\Models\ValueObjects\Actor\ActorId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Coupon\CouponAvailabilityCalendar;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Coupon\CouponDiffusion;
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\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\ProcessingCodeArray;
final readonly class CouponFound implements Output
{
/**
* @param array<Campaign> $campaigns
*/
public function __construct(
public CouponCode $code,
public CouponName $name,
public MoneyAmount $amount,
public CouponPicture $picture,
public CouponDiffusion $diffusion,
public ?CouponAvailabilityCalendar $availabilityCalendar,
public CouponValidityInformationArray $validityInformations,
public CouponLegalInformationArray $legalInformations,
public ProcessingCodeArray $processingCode,
public BrandCode $brandCode,
public ActorId $createdBy,
public CouponProductArray $products,
public CouponMovementArray $movements,
public int $quantity,
public array $campaigns,
public \DateTimeInterface $createdAt,
public ?\DateTimeInterface $updatedAt,
) {
}
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Coupon\Find\Output;
final readonly class CouponNotFound implements Output
{
}

View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Coupon\Find\Output;
use Shared\DDD\UseCases\Output as UseCaseOutput;
interface Output extends UseCaseOutput
{
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Coupon\List;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Coupon\CouponAvailabilityCalendar;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Coupon\CouponDiffusion;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
final readonly class CouponListItem
{
public function __construct(
public CouponCode $code,
public CouponName $name,
public BrandCode $brandCode,
public CouponPicture $picture,
public CouponDiffusion $diffusion,
public ?CouponAvailabilityCalendar $availabilityCalendar = null,
) {
}
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Coupon\List;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Coupon\CouponSortCriteria;
use Shared\DDD\UseCases\Input as UseCaseInput;
use Shared\ValueObjects\Pagination\Pagination;
final readonly class Input implements UseCaseInput
{
public function __construct(
public Pagination $pagination,
public ?CouponSortCriteria $sort = null,
public ?BrandCode $brandCode = null,
) {
}
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Coupon\List;
use Domain\Models\Entities\Coupon;
use Domain\Ports\Repositories\CouponRepository;
use Shared\DDD\UseCases\Input as BaseInput;
use Shared\DDD\UseCases\UseCase;
use Shared\ValueObjects\Pagination\Paginated;
use Webmozart\Assert\Assert;
/** @implements UseCase<\Application\UseCases\Coupon\List\Input, \Application\UseCases\Coupon\List\Output> */
final readonly class ListCoupons implements UseCase
{
public function __construct(
private CouponRepository $repository,
) {
}
#[\Override]
public function execute(BaseInput $input): Output
{
Assert::isInstanceOf($input, Input::class);
$result = $this->repository->findAll(
pagination: $input->pagination,
sortCriteria: $input->sort,
brandCode: $input->brandCode,
);
$items = array_map(
static fn (Coupon $coupon): CouponListItem => new CouponListItem(
code: $coupon->code,
name: $coupon->name,
brandCode: $coupon->brandCode,
picture: $coupon->picture,
diffusion: $coupon->diffusion,
availabilityCalendar: $coupon->availabilityCalendar,
),
$result->getItems(),
);
return new Output(
coupons: new Paginated(
pagination: $result->getPagination(),
items: $items,
total: $result->getTotal(),
),
);
}
}

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Coupon\List;
use Shared\DDD\UseCases\Output as UseCaseOutput;
use Shared\ValueObjects\Pagination\Paginated;
final readonly class Output implements UseCaseOutput
{
/** @param Paginated<CouponListItem> $coupons */
public function __construct(
public Paginated $coupons,
) {
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Coupon\Update;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Coupon\CouponLegalInformationArray;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Shared\DDD\UseCases\Input as UseCaseInput;
use Webmozart\Assert\Assert;
final readonly class Input implements UseCaseInput
{
public function __construct(
public CouponCode $code,
public ?CouponPicture $picture = null,
public ?CouponValidityInformationArray $validityInformations = null,
public ?CouponLegalInformationArray $legalInformations = null,
) {
Assert::true(
$this->picture instanceof CouponPicture
|| $this->validityInformations instanceof CouponValidityInformationArray
|| $this->legalInformations instanceof CouponLegalInformationArray,
'At least one field must be provided to update the coupon.',
);
}
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Coupon\Update\Output;
final readonly class CouponNotFound implements Output
{
}

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Coupon\Update\Output;
use Domain\Models\ValueObjects\Actor\ActorId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Coupon\CouponAvailabilityCalendar;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Coupon\CouponDiffusion;
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\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\ProcessingCodeArray;
final readonly class CouponUpdated implements Output
{
public function __construct(
public CouponCode $code,
public CouponName $name,
public MoneyAmount $amount,
public CouponPicture $picture,
public CouponDiffusion $diffusion,
public ?CouponAvailabilityCalendar $availabilityCalendar,
public CouponValidityInformationArray $validityInformations,
public CouponLegalInformationArray $legalInformations,
public ProcessingCodeArray $processingCode,
public BrandCode $brandCode,
public ActorId $createdBy,
public CouponProductArray $products,
public CouponMovementArray $movements,
) {
}
}

View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Coupon\Update\Output;
use Shared\DDD\UseCases\Output as UseCaseOutput;
interface Output extends UseCaseOutput
{
}

View file

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Coupon\Update;
use Application\UseCases\Coupon\Update\Output\CouponNotFound;
use Application\UseCases\Coupon\Update\Output\CouponUpdated;
use Application\UseCases\Coupon\Update\Output\Output;
use Domain\Models\Entities\Coupon;
use Domain\Models\ValueObjects\Coupon\CouponLegalInformationArray;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Ports\Repositories\CouponRepository;
use Shared\DDD\UseCases\Input as BaseInput;
use Shared\DDD\UseCases\Transactional;
use Shared\DDD\UseCases\UseCase;
use Webmozart\Assert\Assert;
/** @implements UseCase<\Application\UseCases\Coupon\Update\Input, \Application\UseCases\Coupon\Update\Output\Output> */
final readonly class UpdateCoupon implements UseCase, Transactional
{
public function __construct(
private CouponRepository $repository,
) {
}
#[\Override]
public function execute(BaseInput $input): Output
{
Assert::isInstanceOf($input, Input::class);
$coupon = $this->repository->findByCode($input->code);
if (! $coupon instanceof Coupon) {
return new CouponNotFound();
}
if ($input->picture instanceof CouponPicture) {
$coupon = $coupon->changePicture($input->picture);
}
if (
$input->validityInformations instanceof CouponValidityInformationArray
|| $input->legalInformations instanceof CouponLegalInformationArray
) {
$coupon = $coupon->changeInformations(
$input->validityInformations ?? $coupon->validityInformations,
$input->legalInformations ?? $coupon->legalInformations,
);
}
$this->repository->save($coupon);
return new CouponUpdated(
code: $coupon->code,
name: $coupon->name,
amount: $coupon->amount,
picture: $coupon->picture,
diffusion: $coupon->diffusion,
availabilityCalendar: $coupon->availabilityCalendar,
validityInformations: $coupon->validityInformations,
legalInformations: $coupon->legalInformations,
processingCode: $coupon->processingCode,
brandCode: $coupon->brandCode,
createdBy: $coupon->createdBy,
products: $coupon->products,
movements: $coupon->movements,
);
}
}

View file

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\PrintableCoupon\Create;
use Domain\Models\Entities\Campaign;
use Domain\Models\Entities\Coupon;
use Domain\Models\Entities\PrintableCoupon;
use Domain\Models\ValueObjects\Campaign\CampaignCoupon;
use Domain\Models\ValueObjects\CrmHashcode;
use Domain\Models\ValueObjects\PrintRules;
use Domain\Ports\Repositories\CampaignRepository;
use Domain\Ports\Repositories\CouponRepository;
use Domain\Ports\Repositories\PrintableCouponRepository;
use Shared\DDD\UseCases\Input as BaseInput;
use Shared\DDD\UseCases\UseCase;
use Shared\DDD\UseCases\Transactional;
use Webmozart\Assert\Assert;
/** @implements UseCase<\Application\UseCases\PrintableCoupon\Create\Input, \Application\UseCases\PrintableCoupon\Create\Output> */
final readonly class CreatePrintableCoupon implements UseCase, Transactional
{
public function __construct(
private PrintableCouponRepository $printableCouponRepository,
private CouponRepository $couponRepository,
private CampaignRepository $campaignRepository,
) {
}
#[\Override]
public function execute(BaseInput $input): Output
{
Assert::isInstanceOf($input, Input::class);
$campaign = $this->campaignRepository->findByCode($input->campaignCode);
if (! $campaign instanceof Campaign) {
throw new \DomainException(sprintf('Campaign from code "%s" not found.', $input->campaignCode->value));
}
if (! $campaign->publication) {
throw new \DomainException('Campaign must be published.');
}
$coupon = $this->couponRepository->findByCode($input->couponCode);
if (! $coupon instanceof Coupon) {
throw new \DomainException(sprintf('Coupon code "%s" not found.', $input->couponCode->value));
}
$campaignCoupon = $this->campaignRepository->findCampaignCoupon($campaign->code, $coupon->code);
if (! $campaignCoupon instanceof CampaignCoupon) {
throw new \DomainException('Coupon must be associated to the associated campaign coupon.');
}
$period = $coupon->getActivePeriod();
$this->checkPrintRulesFromCampaignCoupon($campaign, $campaignCoupon, $coupon, $input->crmCustomerHashcode);
$printableCoupon = PrintableCoupon::create(
campaignCode: $campaign->code,
couponCode: $coupon->code,
couponName: $coupon->name,
crmCustomerHashcode: $input->crmCustomerHashcode,
distributableUntil: $period->end,
);
$this->printableCouponRepository->save($printableCoupon);
return new Output(
campaignCode: $printableCoupon->campaignCode,
couponCode: $printableCoupon->couponCode,
couponName: $printableCoupon->couponName,
crmCustomerHashcode: $printableCoupon->crmCustomerHashcode,
distributableUntil: $printableCoupon->distributableUntil,
);
}
public function checkPrintRulesFromCampaignCoupon(
Campaign $campaign,
CampaignCoupon $campaignCoupon,
Coupon $coupon,
CrmHashcode $crmCustomerHashcode,
): void {
if ($campaignCoupon->printRules instanceof PrintRules) {
$countPrintedCoupon = $this->printableCouponRepository->countPrintedCouponForCustomer(
$campaign->code,
$coupon->code,
$crmCustomerHashcode,
);
$campaignCoupon->printRules->hasPrintLimitNotReached($countPrintedCoupon);
$lastPrintedCoupon = $this->printableCouponRepository->findLastPrintedCouponForCustomer(
$campaign->code,
$coupon->code,
$crmCustomerHashcode,
);
$diff = $lastPrintedCoupon->printedAt()->diff(new \DateTime());
$campaignCoupon->printRules->hasReprintPeriodExpired($diff->days);
}
}
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\PrintableCoupon\Create;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\CrmHashcode;
use Shared\DDD\UseCases\Input as UseCaseInput;
final readonly class Input implements UseCaseInput
{
public function __construct(
public CampaignCode $campaignCode,
public CouponCode $couponCode,
public CrmHashcode $crmCustomerHashcode,
) {
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\PrintableCoupon\Create;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\CrmHashcode;
use Shared\DDD\UseCases\Output as UseCaseOutput;
final readonly class Output implements UseCaseOutput
{
public function __construct(
public CampaignCode $campaignCode,
public CouponCode $couponCode,
public CouponName $couponName,
public CrmHashcode $crmCustomerHashcode,
public \DateTimeInterface $distributableUntil,
) {
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\PrintableCoupon\List;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\CrmHashcode;
use Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponSortCriteria;
use Shared\DDD\UseCases\Input as UseCaseInput;
use Shared\ValueObjects\Pagination\Pagination;
final readonly class Input implements UseCaseInput
{
public function __construct(
public Pagination $pagination,
public CampaignCode $campaignCode,
public CrmHashcode $crmCustomerHashcode,
public ?PrintableCouponSortCriteria $sortCriteria = null,
) {
}
}

View file

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\PrintableCoupon\List;
use Domain\Models\Entities\Coupon;
use Domain\Models\Entities\PrintableCoupon;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Ports\Repositories\CouponRepository;
use Domain\Ports\Repositories\PrintableCouponRepository;
use Shared\DDD\UseCases\Input as BaseInput;
use Shared\DDD\UseCases\UseCase;
use Shared\ValueObjects\Pagination\Paginated;
use Webmozart\Assert\Assert;
/** @implements UseCase<\Application\UseCases\PrintableCoupon\List\Input, \Application\UseCases\PrintableCoupon\List\Output> */
final readonly class ListPrintableCoupons implements UseCase
{
public function __construct(
private PrintableCouponRepository $repository,
private CouponRepository $couponRepository,
) {
}
#[\Override]
public function execute(BaseInput $input): Output
{
Assert::isInstanceOf($input, Input::class);
$result = $this->repository->findAllAvailable(
pagination: $input->pagination,
campaignCode: $input->campaignCode,
crmCustomerHashcode: $input->crmCustomerHashcode,
sortCriteria: $input->sortCriteria,
);
$printableCoupons = $result->getItems();
/** @var array<string, CouponPicture> $picturesByCouponCode */
$picturesByCouponCode = [];
foreach ($printableCoupons as $printableCoupon) {
$couponCode = $printableCoupon->couponCode;
if (isset($picturesByCouponCode[$couponCode->value])) {
continue;
}
$coupon = $this->couponRepository->findByCode($couponCode);
if (!$coupon instanceof Coupon) {
throw new \LogicException(sprintf('Coupon "%s" referenced by a printable coupon does not exist.', $couponCode->value));
}
$picturesByCouponCode[$couponCode->value] = $coupon->picture;
}
$items = array_map(
static fn (PrintableCoupon $printableCoupon): PrintableCouponListItem => new PrintableCouponListItem(
code: $printableCoupon->code,
couponCode: $printableCoupon->couponCode,
couponName: $printableCoupon->couponName,
couponPicture: $picturesByCouponCode[$printableCoupon->couponCode->value],
distributableUntil: $printableCoupon->distributableUntil,
),
$printableCoupons,
);
return new Output(
printableCoupons: new Paginated(
pagination: $result->getPagination(),
items: $items,
total: $result->getTotal(),
),
);
}
}

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\PrintableCoupon\List;
use Shared\DDD\UseCases\Output as UseCaseOutput;
use Shared\ValueObjects\Pagination\Paginated;
final readonly class Output implements UseCaseOutput
{
/** @param Paginated<PrintableCouponListItem> $printableCoupons */
public function __construct(
public Paginated $printableCoupons,
) {
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\PrintableCoupon\List;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponCode;
final readonly class PrintableCouponListItem
{
public function __construct(
public PrintableCouponCode $code,
public CouponCode $couponCode,
public CouponName $couponName,
public CouponPicture $couponPicture,
public \DateTimeInterface $distributableUntil,
) {
}
}

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

@ -8,6 +8,7 @@ use Domain\Models\ValueObjects\Actor\ActorDisplayName;
use Domain\Models\ValueObjects\Actor\ActorId;
use Shared\DDD\Models\AggregateRoot;
use Shared\DDD\Models\HasTimestampIdentifier;
use Shared\Helpers\DatesHelper;
/**
* @implements \Shared\DDD\Models\AggregateRoot<\Domain\Models\ValueObjects\Actor\ActorId>
@ -39,12 +40,11 @@ final readonly class Actor implements AggregateRoot, HasTimestampIdentifier
public static function hydrate(
ActorId $id,
ActorDisplayName $displayName,
?\DateTimeInterface $updatedAt = null,
): self {
return new self(
id: $id,
displayName: $displayName,
updatedAt: $updatedAt,
updatedAt: DatesHelper::now(),
);
}
}

View file

@ -51,7 +51,6 @@ final readonly class Brand implements AggregateRoot, HasTimestampIdentifier
PicturePath $logo,
Rcs $rcs,
Email $contact,
?\DateTimeInterface $updatedAt = null,
): Brand {
return new self(
id: BrandId::generate(),
@ -60,7 +59,6 @@ final readonly class Brand implements AggregateRoot, HasTimestampIdentifier
logo: $logo,
rcs: $rcs,
contact: $contact,
updatedAt: $updatedAt,
);
}
@ -71,7 +69,6 @@ final readonly class Brand implements AggregateRoot, HasTimestampIdentifier
PicturePath $logo,
Rcs $rcs,
Email $contact,
?\DateTimeInterface $updatedAt = null,
): self {
return new self(
id: $id,
@ -80,7 +77,7 @@ final readonly class Brand implements AggregateRoot, HasTimestampIdentifier
logo: $logo,
rcs: $rcs,
contact: $contact,
updatedAt: $updatedAt,
updatedAt: DatesHelper::now(),
);
}

View file

@ -7,36 +7,29 @@ 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;
use Shared\DDD\Models\HasTimestampIdentifier;
use Shared\Helpers\DatesHelper;
/**
* @implements \Shared\DDD\Models\AggregateRoot<\Domain\Models\ValueObjects\Campaign\CampaignId>
*/
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,18 +47,14 @@ 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,
): Campaign {
return new self(
id: CampaignId::generate(),
@ -76,23 +65,18 @@ final readonly class Campaign implements AggregateRoot, HasTimestampIdentifier
coupons: $coupons,
brandCode: $brandCode,
actorId: $actorId,
updatedAt: $updatedAt,
);
}
/**
* @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,
): self {
return new self(
id: $id,
@ -103,7 +87,7 @@ final readonly class Campaign implements AggregateRoot, HasTimestampIdentifier
coupons: $coupons,
brandCode: $brandCode,
actorId: $actorId,
updatedAt: $updatedAt,
updatedAt: DatesHelper::now(),
);
}
}

View file

@ -62,7 +62,6 @@ final readonly class CampaignRequest implements AggregateRoot, HasTimestampIdent
Applicant $applicant,
CampaignRequestType $campaignType,
RequestedCoupon $requestedCoupon,
?\DateTimeInterface $updatedAt = null,
): self {
return new self(
id: CampaignRequestId::generate(),
@ -72,8 +71,6 @@ final readonly class CampaignRequest implements AggregateRoot, HasTimestampIdent
campaignType: $campaignType,
requestedCoupon: $requestedCoupon,
status: CampaignRequestStatus::CREATED,
rejectionReason: null,
updatedAt: $updatedAt,
);
}
@ -86,7 +83,6 @@ final readonly class CampaignRequest implements AggregateRoot, HasTimestampIdent
RequestedCoupon $requestedCoupon,
CampaignRequestStatus $status,
?RejectionReason $rejectionReason = null,
?\DateTimeInterface $updatedAt = null,
): self {
return new self(
id: $id,
@ -97,7 +93,7 @@ final readonly class CampaignRequest implements AggregateRoot, HasTimestampIdent
requestedCoupon: $requestedCoupon,
status: $status,
rejectionReason: $rejectionReason,
updatedAt: $updatedAt,
updatedAt: DatesHelper::now(),
);
}

View file

@ -8,30 +8,27 @@ use Domain\Models\ValueObjects\Actor\ActorId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Coupon\CouponAvailabilityCalendar;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Coupon\CouponCountryCoverage;
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\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\ProcessingCode;
use Domain\Models\ValueObjects\Period;
use Domain\Models\ValueObjects\ProcessingCodeArray;
use Shared\DDD\Models\AggregateRoot;
use Shared\DDD\Models\HasTimestampIdentifier;
use Shared\Helpers\DatesHelper;
/**
* @implements \Shared\DDD\Models\AggregateRoot<\Domain\Models\ValueObjects\Coupon\CouponId>
*/
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 +36,22 @@ 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 ProcessingCodeArray $processingCode,
public BrandCode $brandCode,
public ActorId $createdBy,
public array $products = [],
public CouponProductArray $products,
public CouponMovementArray $movements,
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.');
}
}
CouponCountryCoverage::assert(
$this->processingCode,
$this->validityInformations,
$this->legalInformations,
);
$this->validateCalendarPeriodsWithinDiffusionPeriod();
}
public function id(): CouponId
@ -78,27 +69,25 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
return $this->updatedAt;
}
/**
* @param array<CouponValidityInformation> $couponValidityInformation
* @param array<CouponLegalInformation> $couponLegalInformation
* @param array<IncoCode> $products
*/
public function quantity(): int
{
return $this->movements->quantity();
}
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,
ProcessingCodeArray $processingCode,
BrandCode $brandCode,
ActorId $createdBy,
array $products = [],
CouponProductArray $products,
CouponMovementArray $movements,
?CouponAvailabilityCalendar $availabilityCalendar = null,
?GS1 $gs1 = null,
?\DateTimeInterface $updatedAt = null,
): Coupon {
return new self(
id: CouponId::generate(),
@ -107,24 +96,17 @@ 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,
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,16 +114,14 @@ 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,
ProcessingCodeArray $processingCode,
BrandCode $brandCode,
ActorId $createdBy,
array $products = [],
CouponProductArray $products,
CouponMovementArray $movements,
?CouponAvailabilityCalendar $availabilityCalendar = null,
?GS1 $gs1 = null,
?\DateTimeInterface $updatedAt = null,
): self {
return new self(
id: $id,
@ -150,16 +130,89 @@ 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,
availabilityCalendar: $availabilityCalendar,
gs1: $gs1,
updatedAt: $updatedAt,
updatedAt: DatesHelper::now(),
);
}
public function changePicture(CouponPicture $picture): self
{
return new self(
id: $this->id,
code: $this->code,
name: $this->name,
amount: $this->amount,
picture: $picture,
diffusion: $this->diffusion,
validityInformations: $this->validityInformations,
legalInformations: $this->legalInformations,
processingCode: $this->processingCode,
brandCode: $this->brandCode,
createdBy: $this->createdBy,
products: $this->products,
movements: $this->movements,
availabilityCalendar: $this->availabilityCalendar,
updatedAt: DatesHelper::now(),
);
}
public function changeInformations(CouponValidityInformationArray $validityInformations, CouponLegalInformationArray $legalInformations): self
{
return new self(
id: $this->id,
code: $this->code,
name: $this->name,
amount: $this->amount,
picture: $this->picture,
diffusion: $this->diffusion,
validityInformations: $validityInformations,
legalInformations: $legalInformations,
processingCode: $this->processingCode,
brandCode: $this->brandCode,
createdBy: $this->createdBy,
products: $this->products,
movements: $this->movements,
availabilityCalendar: $this->availabilityCalendar,
updatedAt: DatesHelper::now(),
);
}
public function getActivePeriod(): Period
{
$currentDate = new \DateTime();
$diffusionPeriod = $this->diffusion->period;
$period = $this->availabilityCalendar?->isActiveAt($currentDate)
? $this->availabilityCalendar->getCurrentPeriod($currentDate)
?? $diffusionPeriod
: $diffusionPeriod;
if (! $period->hasStarted()) {
throw new \DomainException('The printing period has not yet begun.');
}
if (! $period->hasEnded()) {
throw new \DomainException('The printing period has expired.');
}
return $period;
}
public function validateCalendarPeriodsWithinDiffusionPeriod(): void
{
if ($this->availabilityCalendar instanceof CouponAvailabilityCalendar) {
$diffusionPeriod = $this->diffusion->period;
foreach ($this->availabilityCalendar->periods as $calendarPeriod) {
if (! $diffusionPeriod->inRange($calendarPeriod->start) || ! $diffusionPeriod->inRange($calendarPeriod->end)) {
throw new \DomainException('Calendar period must be in diffusion period.');
}
}
}
}
}

View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Domain\Models\Entities;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponCode;
use Domain\Models\ValueObjects\CrmHashcode;
use Shared\DDD\Models\AggregateRoot;
use Shared\DDD\Models\HasTimestampIdentifier;
use Shared\Helpers\DatesHelper;
/**
* @implements \Shared\DDD\Models\AggregateRoot<\Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponCode>
*/
final readonly class PrintableCoupon implements AggregateRoot, HasTimestampIdentifier
{
private function __construct(
public PrintableCouponCode $code,
public CampaignCode $campaignCode,
public CouponCode $couponCode,
public CouponName $couponName,
public CrmHashcode $crmCustomerHashcode,
public \DateTimeInterface $distributableUntil,
public ?\DateTimeInterface $updatedAt = null,
) {
}
public function id(): PrintableCouponCode
{
return $this->code;
}
public function createdAt(): \DateTimeInterface
{
return $this->code->toDateTime();
}
public function updatedAt(): ?\DateTimeInterface
{
return $this->updatedAt;
}
public static function create(
CampaignCode $campaignCode,
CouponCode $couponCode,
CouponName $couponName,
CrmHashcode $crmCustomerHashcode,
\DateTimeInterface $distributableUntil,
): self {
return new self(
code: PrintableCouponCode::generate(),
campaignCode: $campaignCode,
couponCode: $couponCode,
couponName: $couponName,
crmCustomerHashcode: $crmCustomerHashcode,
distributableUntil: $distributableUntil,
);
}
public static function hydrate(
PrintableCouponCode $code,
CampaignCode $campaignCode,
CouponCode $couponCode,
CouponName $couponName,
CrmHashcode $crmCustomerHashcode,
\DateTimeInterface $distributableUntil,
): self {
return new self(
code: $code,
campaignCode: $campaignCode,
couponCode: $couponCode,
couponName: $couponName,
crmCustomerHashcode: $crmCustomerHashcode,
distributableUntil: $distributableUntil,
updatedAt: DatesHelper::now(),
);
}
}

View file

@ -5,12 +5,13 @@ 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;
use Shared\DDD\Models\AggregateRoot;
use Shared\DDD\Models\HasTimestampIdentifier;
use Shared\Helpers\DatesHelper;
/**
* @implements \Shared\DDD\Models\AggregateRoot<\Domain\Models\ValueObjects\Stock\StockId>
@ -21,7 +22,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,17 +56,16 @@ final readonly class Stock implements AggregateRoot, HasTimestampIdentifier
StockId $id,
StockAmount $stockAmount,
CampaignCode $campaignCode,
ProcessingCode $processingCode,
CouponCode $couponCode,
array $stockAdjustment = [],
?\DateTimeInterface $updatedAt = null,
): self {
return new self(
id: $id,
stockAmount: $stockAmount,
campaignCode: $campaignCode,
processingCode: $processingCode,
couponCode: $couponCode,
stockAdjustment: $stockAdjustment,
updatedAt: $updatedAt,
updatedAt: DatesHelper::now(),
);
}
}

View file

@ -7,9 +7,9 @@ namespace Domain\Models\ValueObjects\Campaign;
final readonly class CampaignCode
{
public function __construct(
public string $code,
public string $value,
) {
if (mb_trim($this->code) === '') {
if (mb_trim($this->value) === '') {
throw new \DomainException('Campaign code cannot be empty.');
}
}

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

@ -10,8 +10,10 @@ use DateTime;
final readonly class CouponAvailabilityCalendar implements Equatable
{
/**
* @param array<Period> $periods
*/
public function __construct(
/** @var Period[] */
public array $periods,
) {
if ($this->periods === []) {
@ -19,7 +21,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 +29,22 @@ 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 => $period->inRange($in));
}
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));
}
public function getCurrentPeriod(DateTime $in): ?Period
{
return array_first(array_filter($this->periods, fn ($period): bool => $period->inRange($in)));
}
#[\Override]

View file

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
use Domain\Models\ValueObjects\ProcessingCodeArray;
use Shared\Enums\Country;
final readonly class CouponCountryCoverage
{
private function __construct()
{
}
public static function assert(
ProcessingCodeArray $processingCodes,
CouponValidityInformationArray $validityInformations,
CouponLegalInformationArray $legalInformations,
): void {
foreach ($processingCodes->value as $processingCode) {
$country = $processingCode->availableIn;
if (! self::hasInformationFor($country, $validityInformations)) {
throw new \DomainException(sprintf('A coupon requires validity information for %s.', $country->name));
}
if (! self::hasInformationFor($country, $legalInformations)) {
throw new \DomainException(sprintf('A coupon requires legal information for %s.', $country->name));
}
}
self::countDiffusionCountries($validityInformations, $processingCodes);
self::countDiffusionCountries($legalInformations, $processingCodes);
}
private static function hasInformationFor(
Country $country,
CouponValidityInformationArray | CouponLegalInformationArray $informations,
): bool {
return array_any(
$informations->value,
fn (CouponValidityInformation | CouponLegalInformation $information): bool => $information->forLocale === $country,
);
}
private static function countDiffusionCountries(
CouponValidityInformationArray | CouponLegalInformationArray $informations,
ProcessingCodeArray $processingCodes,
): void {
if (count($informations->value) !== count($processingCodes->value)) {
throw new \DomainException('A coupon requires exactly one information per diffusion country.');
}
}
}

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,45 @@
<?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;
$countries = [];
foreach ($this->value as $couponLegalInformation) {
if (! $couponLegalInformation instanceof CouponLegalInformation) {
throw new \DomainException('Value of CouponLegalInformationArray must be CouponLegalInformation instances.');
}
if (isset($countries[$couponLegalInformation->forLocale->name])) {
throw new \DomainException(sprintf(
'Only one CouponLegalInformation is allowed per country, duplicate for %s.',
$couponLegalInformation->forLocale->name,
));
}
$countries[$couponLegalInformation->forLocale->name] = true;
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,28 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
final readonly class CouponMovementArray
{
public function __construct(
/** @var CouponMovement[] */
public array $value,
) {
if ($this->value === []) {
throw new \DomainException('CouponMovementArray requires at least one CouponMovement.');
}
foreach ($this->value as $couponMovement) {
if (! $couponMovement instanceof CouponMovement) {
throw new \DomainException('Value of CouponMovementArray must be CouponMovement instances.');
}
}
}
public function quantity(): int
{
return array_sum(array_column($this->value, 'quantity'));
}
}

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

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
use Domain\Models\ValueObjects\PicturePath;
final readonly class CouponPicture
{
private const array ALLOWED_EXTENSIONS = ['jpg', 'jpeg'];
public const int DECORATION_WIDTH = 280;
public const int DECORATION_HEIGHT = 260;
public function __construct(
public PicturePath $decoration,
public PicturePath $thumbnail,
public PicturePath $mobileThumbnail,
) {
foreach ([$decoration, $thumbnail, $mobileThumbnail] as $picture) {
$extension = strtolower(pathinfo($picture->value, PATHINFO_EXTENSION));
if (! in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
throw new \DomainException(sprintf('Coupon picture "%s" must be a JPG file.', $picture));
}
}
}
public static function create(
PicturePath $decoration,
PicturePath $thumbnail,
PicturePath $mobileThumbnail,
): self {
$picture = new self($decoration, $thumbnail, $mobileThumbnail);
$size = is_file($decoration->value) ? getimagesize($decoration->value) : false;
if ($size === false) {
throw new \DomainException(sprintf('Unable to read the decoration picture "%s".', $decoration));
}
[$width, $height] = $size;
if ($width !== self::DECORATION_WIDTH || $height !== self::DECORATION_HEIGHT) {
throw new \DomainException(sprintf(
'The decoration picture must be %d x %d px, %d x %d px given.',
self::DECORATION_WIDTH,
self::DECORATION_HEIGHT,
$width,
$height,
));
}
return $picture;
}
}

View file

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

View file

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

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
final readonly class CouponSortCriteria
{
public function __construct(
public CouponSortField $field = CouponSortField::CreatedAt,
public \SortDirection $direction = \SortDirection::Ascending,
) {
}
}

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
enum CouponSortField: string
{
case BrandCode = 'brand_code';
case Diffusion = 'diffusion';
case CreatedAt = 'created_at';
}

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,45 @@
<?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;
$countries = [];
foreach ($this->value as $couponValidityInformation) {
if (! $couponValidityInformation instanceof CouponValidityInformation) {
throw new \DomainException('Value of CouponValidityInformationArray must be CouponValidityInformation instances.');
}
if (isset($countries[$couponValidityInformation->forLocale->name])) {
throw new \DomainException(sprintf(
'Only one CouponValidityInformation is allowed per country, duplicate for %s.',
$couponValidityInformation->forLocale->name,
));
}
$countries[$couponValidityInformation->forLocale->name] = true;
if ($couponValidityInformation->forLocale === Country::FRANCE) {
$hasFrance = true;
}
}
if (! $hasFrance) {
throw new \DomainException('CouponValidityInformations requires a CouponValidityInformation for France.');
}
}
}

View file

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects;
final readonly class CouponPicture
{
public function __construct(
public PicturePath $decoration,
public PicturePath $thumbnail,
public PicturePath $inactiveThumbnail
) {
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use Ramsey\Uuid\UuidInterface;
use Shared\DDD\Models\Equatable;
use Shared\Helpers\UuidHelper;
final readonly class CrmHashcode implements Equatable
{
public function __construct(
public UuidInterface $value,
) {
if (! UuidHelper::isV4($value)) {
throw new \DomainException(
'Hash code must be a valid UUID v4.',
);
}
}
#[\Override]
public function equals(mixed $other): bool
{
return $other instanceof self && $this->value === $other->value;
}
}

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

@ -4,9 +4,7 @@ declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use Shared\DDD\Models\Equatable;
final readonly class IncoCode implements Equatable
final readonly class IncoCode
{
/**
* @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

@ -4,16 +4,31 @@ declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use DateTime;
use Shared\Helpers\DatesHelper;
final readonly class Period
{
public function __construct(
public DateTime $start,
public DateTime $end,
public \DateTime $start,
public \DateTime $end,
) {
if ($this->end < $this->start) {
throw new \DomainException('Period end date cannot be before its start date.');
}
}
public function hasStarted(): bool
{
return $this->start <= DatesHelper::now();
}
public function hasEnded(): bool
{
return $this->end >= DatesHelper::now();
}
public function inRange(\DateTime $date): bool
{
return $date >= $this->start && $date <= $this->end;
}
}

View file

@ -11,15 +11,33 @@ final readonly class PrintRules
public ?int $cooldownDays = null,
) {
if (null === $this->maxPerCustomer && null === $this->cooldownDays) {
throw new \InvalidArgumentException('PrintRules require at least a max per customer or a cooldown days.');
throw new \DomainException('PrintRules require at least a max per customer or a cooldown days.');
}
if (null !== $this->maxPerCustomer && $this->maxPerCustomer <= 0) {
throw new \InvalidArgumentException('Max per customer must be greater than 0.');
throw new \DomainException('Max per customer must be greater than 0.');
}
if (null !== $this->cooldownDays && $this->cooldownDays <= 0) {
throw new \InvalidArgumentException('Cooldown days must be greater than 0.');
if (null !== $this->cooldownDays && $this->cooldownDays < 30) {
throw new \DomainException('Cooldown days must be greater than 30.');
}
if ($this->maxPerCustomer && $this->cooldownDays) {
throw new \DomainException('No more than one print rule.');
}
}
public function hasPrintLimitNotReached(int $countPrintedCoupon): void
{
if ($this->maxPerCustomer && $this->maxPerCustomer < $countPrintedCoupon) {
throw new \DomainException('Maximum number of prints reached.');
}
}
public function hasReprintPeriodExpired(int $diffDays): void
{
if ($this->cooldownDays && $diffDays < $this->cooldownDays) {
throw new \DomainException('Reprint period has not expired.');
}
}
}

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\PrintableCoupon;
use Shared\DDD\Models\Equatable;
final readonly class MarketingDirectCode implements \Stringable, Equatable
{
private const string PATTERN = '/^\d{13}\z/';
public function __construct(
public string $value,
) {
if (preg_match(self::PATTERN, $this->value) !== 1) {
throw new \DomainException(
'Marketing Direct Code must be exactly 13 numeric characters, e.g. 5425003944130.',
);
}
}
public static function fromInt(int $sequence): self
{
if ($sequence < 0) {
throw new \DomainException('Marketing Direct sequence must be positive.');
}
return new self(str_pad((string) $sequence, 13, '0', STR_PAD_LEFT));
}
public function toInt(): int
{
return (int) $this->value;
}
#[\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,16 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\PrintableCoupon;
use Ramsey\Uuid\Uuid;
use Shared\DDD\Models\Uuidv7EntityId;
final readonly class PrintableCouponCode extends Uuidv7EntityId
{
public static function generate(): self
{
return new self(Uuid::uuid7());
}
}

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\PrintableCoupon;
final readonly class PrintableCouponSortCriteria
{
public function __construct(
public PrintableCouponSortField $field = PrintableCouponSortField::Name,
public \SortDirection $direction = \SortDirection::Ascending,
) {
}
}

View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\PrintableCoupon;
enum PrintableCouponSortField: string
{
case CreatedAt = 'created_at';
case Name = 'name';
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\PrintableCoupon;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\CrmHashcode;
final readonly class PrintedCoupon
{
public function __construct(
public PrintedCouponId $id,
public MarketingDirectCode $marketingCode,
public PrintableCouponCode $printableCode,
public CouponCode $couponCode,
public CampaignCode $campaignCode,
public CrmHashcode $customer,
public \DateTimeInterface $printedAt,
) {
}
public function printedAt(): \DateTimeInterface
{
return $this->id->toDateTime();
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\PrintableCoupon;
use Ramsey\Uuid\Uuid;
use Shared\DDD\Models\Uuidv7EntityId;
final readonly class PrintedCouponId extends Uuidv7EntityId
{
public static function generate(): self
{
return new self(Uuid::uuid7());
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\PrintableCoupon;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\CrmHashcode;
final readonly class UnprintedCoupon
{
public function __construct(
public PrintableCouponCode $printableCode,
public CouponCode $couponCode,
public CampaignCode $campaignCode,
public CrmHashcode $customer,
public UnprintedReason $unprintedReason,
) {
}
}

View file

@ -2,15 +2,15 @@
declare(strict_types=1);
namespace Domain\Models\ValueObjects;
namespace Domain\Models\ValueObjects\PrintableCoupon;
final readonly class CouponName
final readonly class UnprintedReason
{
public function __construct(
public string $value,
) {
if (mb_trim($this->value) === '') {
throw new \DomainException('Coupon name cannot be empty.');
throw new \DomainException('Unprinted reason cannot be empty.');
}
}
}

View file

@ -5,14 +5,20 @@ declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use Shared\DDD\Models\Equatable;
use Shared\Enums\Country;
final readonly class ProcessingCode implements \Stringable, Equatable
{
private const string PATTERN = '/^\d{13}\z/';
public function __construct(
public string $value,
public Country $availableIn,
) {
if (mb_trim($this->value) === '') {
throw new \DomainException('Processing Code value cannot be empty.');
if (preg_match(self::PATTERN, $this->value) !== 1) {
throw new \DomainException(
'Processing Code must be exactly 13 numeric characters, e.g. 5425003944130.',
);
}
}

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use Shared\Enums\Country;
final readonly class ProcessingCodeArray
{
public function __construct(
/** @var ProcessingCode[] */
public array $value,
) {
if ($this->value === []) {
throw new \DomainException('ProcessingCodes requires at least one ProcessingCode.');
}
$hasFrance = false;
$countries = [];
foreach ($this->value as $processingCode) {
if (! $processingCode instanceof ProcessingCode) {
throw new \DomainException('Value of ProcessingCodeArray must be ProcessingCode instances.');
}
if (isset($countries[$processingCode->availableIn->name])) {
throw new \DomainException(sprintf(
'Only one ProcessingCode is allowed per country, duplicate for %s.',
$processingCode->availableIn->name,
));
}
$countries[$processingCode->availableIn->name] = true;
if ($processingCode->availableIn === Country::FRANCE) {
$hasFrance = true;
}
}
if (! $hasFrance) {
throw new \DomainException('ProcessingCodes requires a ProcessingCode for France.');
}
}
}

View file

@ -4,58 +4,35 @@ declare(strict_types=1);
namespace Domain\Models\ValueObjects\RequestedCoupon;
use Domain\Models\ValueObjects\Coupon\CouponValidity;
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\CouponCountryCoverage;
use Domain\Models\ValueObjects\Coupon\CouponLegalInformationArray;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\Coupon\CouponProductArray;
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;
use Domain\Models\ValueObjects\ProcessingCodeArray;
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 ProcessingCodeArray $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.');
}
}
private function hasValidityInformationFor(Country $country): bool
{
foreach ($this->couponValidityInformation as $validityInformation) {
if ($validityInformation->forLocale === $country) {
return true;
}
}
return false;
CouponCountryCoverage::assert(
$this->couponProcessingCodes,
$this->couponValidityInformations,
$this->couponLegalInformations,
);
}
}

View file

@ -4,6 +4,10 @@ declare(strict_types=1);
namespace Domain\Ports\Repositories;
use Domain\Models\Entities\Campaign;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\Campaign\CampaignCoupon;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Shared\DDD\Ports\Repository;
/**
@ -11,4 +15,14 @@ use Shared\DDD\Ports\Repository;
*/
interface CampaignRepository extends Repository
{
/**
* Returns the campaigns that currently contain the given coupon
*
* @return array<Campaign>
*/
public function findByCouponCode(CouponCode $couponCode): array;
public function findByCode(CampaignCode $code): ?Campaign;
public function findCampaignCoupon(CampaignCode $campaignCode, CouponCode $couponCode): ?CampaignCoupon;
}

View file

@ -4,11 +4,31 @@ declare(strict_types=1);
namespace Domain\Ports\Repositories;
use Domain\Models\Entities\Coupon;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Coupon\CouponSortCriteria;
use Shared\DDD\Ports\Repository;
use Shared\ValueObjects\Pagination\Paginated;
use Shared\ValueObjects\Pagination\Pagination;
/**
* @extends \Shared\DDD\Ports\Repository<\Domain\Models\ValueObjects\Coupon\CouponId, \Domain\Models\Entities\Coupon>
*/
interface CouponRepository extends Repository
{
public function existsByCode(CouponCode $code): bool;
public function findByCode(CouponCode $code): ?Coupon;
/**
* @return Paginated<Coupon>
*/
public function findAll(
Pagination $pagination,
?CouponSortCriteria $sortCriteria = null,
?BrandCode $brandCode = null,
): Paginated;
public function save(Coupon $brand): void;
}

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Domain\Ports\Repositories;
use Domain\Models\Entities\PrintableCoupon;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\CrmHashcode;
use Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponSortCriteria;
use Domain\Models\ValueObjects\PrintableCoupon\PrintedCoupon;
use Shared\DDD\Ports\Repository;
use Shared\ValueObjects\Pagination\Paginated;
use Shared\ValueObjects\Pagination\Pagination;
/**
* @extends \Shared\DDD\Ports\Repository<\Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponCode, \Domain\Models\Entities\PrintableCoupon>
*/
interface PrintableCouponRepository extends Repository
{
/**
* @return Paginated<PrintableCoupon>
*/
public function findAllAvailable(
Pagination $pagination,
CampaignCode $campaignCode,
CrmHashcode $crmCustomerHashcode,
?PrintableCouponSortCriteria $sortCriteria = null,
): Paginated;
public function countPrintedCouponForCustomer(
CampaignCode $campaignCode,
CouponCode $couponCode,
CrmHashcode $crmCustomerHashcode,
): int;
public function findLastPrintedCouponForCustomer(
CampaignCode $campaignCode,
CouponCode $couponCode,
CrmHashcode $crmCustomerHashcode,
): PrintedCoupon;
public function save(PrintableCoupon $printableCoupon): void;
}

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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -7,7 +7,6 @@ namespace Tests\Unit\Application\UseCases\Brand\Create;
use Application\UseCases\Brand\Create\CreateBrand;
use Application\UseCases\Brand\Create\Input;
use Application\UseCases\Brand\Create\Output;
use Domain\Exceptions\BrandCodeAlreadyExistsException;
use Domain\Models\Entities\Brand;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandName;
@ -77,7 +76,7 @@ describe('create brand use case validation rules', function (): void {
$action = fn (): Output => $useCase->execute($input);
// THEN
expect($action)->toThrow(BrandCodeAlreadyExistsException::class);
expect($action)->toThrow(\DomainException::class);
});
});

View file

@ -21,16 +21,16 @@ use Shared\ValueObjects\Rcs;
covers(UpdateBrand::class, Input::class, BrandUpdated::class, BrandNotFound::class);
$existingBrand = static fn (BrandCode $code): Brand => Brand::hydrate(
BrandId::generate(),
$code,
new BrandName('Name'),
new PicturePath('files/picture-old.jpg'),
new Rcs('RCS PARIS 111 222 333'),
new Email('contact-old@example.com'),
);
describe('update brand use case', function (): void {
$existingBrand = static fn (BrandCode $code): Brand => Brand::hydrate(
BrandId::generate(),
$code,
new BrandName('Name'),
new PicturePath('files/picture-old.jpg'),
new Rcs('RCS PARIS 111 222 333'),
new Email('contact-old@example.com'),
);
describe('update brand use case', function () use ($existingBrand): void {
it('updates every provided field', function () use ($existingBrand): void {
// GIVEN
$code = new BrandCode('AAA01');
@ -47,8 +47,7 @@ describe('update brand use case', function () use ($existingBrand): void {
&& $saved->rcs === $newRcs
&& $saved->contact === $newContact
&& $saved->code === $brand->code
&& $saved->name === $brand->name
&& $saved->updatedAt !== null,
&& $saved->name === $brand->name,
));
$useCase = new UpdateBrand($repository);
@ -58,8 +57,10 @@ describe('update brand use case', function () use ($existingBrand): void {
// THEN
expect($result)->toBeInstanceOf(BrandUpdated::class)
->and($result->code)->toBe($brand->code)
->and($result->updatedAt)->not->toBeNull();
->and($result->logo)->toBe($newLogo)
->and($result->rcs)->toBe($newRcs)
->and($result->contact)->toBe($newContact)
->and($result->code)->toBe($brand->code);
});
it('updates only the contact and leaves logo/rcs untouched', function () use ($existingBrand): void {
@ -76,8 +77,7 @@ describe('update brand use case', function () use ($existingBrand): void {
&& $saved->logo === $brand->logo
&& $saved->rcs === $brand->rcs
&& $saved->code === $brand->code
&& $saved->name === $brand->name
&& $saved->updatedAt !== null,
&& $saved->name === $brand->name,
));
$useCase = new UpdateBrand($repository);
@ -87,8 +87,8 @@ describe('update brand use case', function () use ($existingBrand): void {
// THEN
expect($result)->toBeInstanceOf(BrandUpdated::class)
->and($result->code)->toBe($brand->code)
->and($result->updatedAt)->not->toBeNull();
->and($result->contact)->toBe($newContact)
->and($result->code)->toBe($brand->code);
});
});

View file

@ -8,21 +8,26 @@ use Application\UseCases\CampaignRequest\Create\CreateCampaignRequest;
use Application\UseCases\CampaignRequest\Create\Input;
use Application\UseCases\CampaignRequest\Create\Output;
use DateTime;
use Domain\Exceptions\BrandNotFoundException;
use Domain\Models\Entities\CampaignRequest;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Campaign\CampaignName;
use Domain\Models\ValueObjects\CampaignRequest\Applicant;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestStatus;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestType;
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\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\IncoCode;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\Period;
use Domain\Models\ValueObjects\PicturePath;
use Domain\Models\ValueObjects\PrintRules;
use Domain\Models\ValueObjects\ProcessingCode;
use Domain\Models\ValueObjects\ProcessingCodeArray;
use Domain\Models\ValueObjects\RequestedCoupon\RequestedCoupon;
use Domain\Models\ValueObjects\RequestedCoupon\RequestedCouponQuantity;
use Domain\Ports\Repositories\BrandRepository;
@ -34,33 +39,45 @@ use Shared\ValueObjects\Money;
covers(CreateCampaignRequest::class, Input::class, Output::class);
beforeEach(function (): void {
$this->input = new Input(
describe('create campaign request use case', function (): void {
$makeInput = fn (): Input => new Input(
brandCode: new BrandCode('AAA01'),
name: new CampaignName('Campaign name'),
applicant: new Applicant('John', 'Doe', new Email('john.doe@example.com')),
campaignType: CampaignRequestType::CLUB,
requestedCoupon: new RequestedCoupon(
name: new CouponName('Coupon A'),
couponName: new CouponName('Coupon A'),
quantity: new RequestedCouponQuantity(100),
diffusion: new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')),
moneyAmount: new MoneyAmount(new Money(Currency::EUR, 300)),
processingCode: new ProcessingCode('PC1'),
couponValidity: new CouponValidity(30, new DateTime('2026-02-01')),
couponValidityInformation: [
couponPicture: new CouponPicture(
new PicturePath('files/product.jpg'),
new PicturePath('files/thumbnail.jpg'),
new PicturePath('files/mobileThumbnail.jpg')
),
couponProcessingCodes: new ProcessingCodeArray([
new ProcessingCode('5425003944130', Country::FRANCE),
new ProcessingCode('4006381333931', Country::BENELUX),
]),
couponValidityInformations: new CouponValidityInformationArray([
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
],
products: [new IncoCode(35784), new IncoCode(15522)],
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX)
]),
couponLegalInformations: new CouponLegalInformationArray([
new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX)
]),
couponProducts: new CouponProductArray([new IncoCode(35784), new IncoCode(15522)]),
printRules: new PrintRules(maxPerCustomer: 3)
),
);
});
describe('create campaign request use case', function (): void {
it('creates a campaign request when the brand exists', function (): void {
it('creates a campaign request when the brand exists', function () use ($makeInput): void {
// GIVEN
$input = $makeInput();
$brandRepository = \Mockery::mock(BrandRepository::class);
$brandRepository->shouldReceive('existsByCode')->once()->with($this->input->brandCode)->andReturn(true);
$brandRepository->shouldReceive('existsByCode')->once()->with($input->brandCode)->andReturn(true);
$campaignRequestRepository = \Mockery::mock(CampaignRequestRepository::class);
$campaignRequestRepository->shouldReceive('save')->once()->with(\Mockery::type(CampaignRequest::class));
@ -68,20 +85,20 @@ describe('create campaign request use case', function (): void {
$useCase = new CreateCampaignRequest($campaignRequestRepository, $brandRepository);
// WHEN
$result = $useCase->execute($this->input);
$result = $useCase->execute($input);
// THEN
expect($result)->toBeInstanceOf(Output::class)
->and($result->status)->toBe(CampaignRequestStatus::CREATED)
->and($result->brandCode)->toBe($this->input->brandCode);
->and($result->brandCode)->toBe($input->brandCode);
});
});
describe('create campaign request use case validation rules', function (): void {
it('throws when the brand does not exist', function (): void {
it('throws when the brand does not exist', function () use ($makeInput): void {
// GIVEN
$input = $makeInput();
$brandRepository = \Mockery::mock(BrandRepository::class);
$brandRepository->shouldReceive('existsByCode')->once()->with($this->input->brandCode)->andReturn(false);
$brandRepository->shouldReceive('existsByCode')->once()->with($input->brandCode)->andReturn(false);
$campaignRequestRepository = \Mockery::mock(CampaignRequestRepository::class);
$campaignRequestRepository->shouldNotReceive('save');
@ -89,9 +106,9 @@ describe('create campaign request use case validation rules', function (): void
$useCase = new CreateCampaignRequest($campaignRequestRepository, $brandRepository);
// WHEN
$action = fn (): Output => $useCase->execute($this->input);
$action = fn (): Output => $useCase->execute($input);
// THEN
expect($action)->toThrow(BrandNotFoundException::class);
expect($action)->toThrow(\DomainException::class);
});
});

View file

@ -10,8 +10,6 @@ use Application\UseCases\CampaignRequest\Find\Output\CampaignRequestFound;
use Application\UseCases\CampaignRequest\Find\Output\CampaignRequestNotFound;
use Application\UseCases\CampaignRequest\Find\Output\Output;
use DateTime;
use Domain\Enums\BarcodeFormat;
use Domain\Exceptions\BrandNotFoundException;
use Domain\Models\Entities\Brand;
use Domain\Models\Entities\CampaignRequest;
use Domain\Models\ValueObjects\Brand\BrandCode;
@ -23,16 +21,20 @@ use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestId;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestStatus;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestType;
use Domain\Models\ValueObjects\CampaignRequest\RejectionReason;
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\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\CouponName;
use Domain\Models\ValueObjects\GS1;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\IncoCode;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\Period;
use Domain\Models\ValueObjects\PicturePath;
use Domain\Models\ValueObjects\PrintRules;
use Domain\Models\ValueObjects\ProcessingCode;
use Domain\Models\ValueObjects\ProcessingCodeArray;
use Domain\Models\ValueObjects\RequestedCoupon\RequestedCoupon;
use Domain\Models\ValueObjects\RequestedCoupon\RequestedCouponQuantity;
use Domain\Ports\Repositories\BrandRepository;
@ -47,44 +49,52 @@ use Shared\ValueObjects\Rcs;
covers(FindCampaignRequest::class, Input::class, CampaignRequestFound::class, CampaignRequestNotFound::class);
describe('find campaign request use case', function (): void {
beforeEach(function (): void {
$this->requestedCoupon = new RequestedCoupon(
new CouponName('Coupon A'),
new RequestedCouponQuantity(100),
new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')),
new MoneyAmount(new Money(Currency::EUR, 300)),
new ProcessingCode('PC1'),
new CouponValidity(30, new DateTime('2026-02-01')),
[
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX)
],
[new IncoCode(35784), new IncoCode(15522)],
new PrintRules(maxPerCustomer: 3),
new GS1('01234567890128', BarcodeFormat::EAN13)
);
});
$requestedCoupon = new RequestedCoupon(
couponName: new CouponName('Coupon A'),
quantity: new RequestedCouponQuantity(100),
diffusion: new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')),
moneyAmount: new MoneyAmount(new Money(Currency::EUR, 300)),
couponPicture: new CouponPicture(
new PicturePath('files/product.jpg'),
new PicturePath('files/thumbnail.jpg'),
new PicturePath('files/mobileThumbnail.jpg')
),
couponProcessingCodes: new ProcessingCodeArray([
new ProcessingCode('5425003944130', Country::FRANCE),
new ProcessingCode('4006381333931', Country::BENELUX),
]),
couponValidityInformations: new CouponValidityInformationArray([
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX)
]),
couponLegalInformations: new CouponLegalInformationArray([
new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX)
]),
couponProducts: new CouponProductArray([new IncoCode(35784), new IncoCode(15522)]),
printRules: new PrintRules(maxPerCustomer: 3),
);
it('finds a campaign request by its id, along with its brand', function (): void {
it('finds a campaign request by its id, along with its brand', function () use ($requestedCoupon): void {
// GIVEN
$id = CampaignRequestId::generate();
$brandCode = new BrandCode('AAA01');
$campaignRequest = CampaignRequest::hydrate(
$id,
$brandCode,
new CampaignName('Campaign A'),
new Applicant('John', 'Doe', new Email('john.doe@example.com')),
CampaignRequestType::CLUB,
$this->requestedCoupon,
CampaignRequestStatus::CREATED,
id: $id,
brandCode: $brandCode,
name: new CampaignName('Campaign A'),
applicant: new Applicant('John', 'Doe', new Email('john.doe@example.com')),
campaignType: CampaignRequestType::CLUB,
requestedCoupon: $requestedCoupon,
status: CampaignRequestStatus::CREATED,
);
$brand = Brand::hydrate(
BrandId::generate(),
$brandCode,
new BrandName('Brand A'),
new PicturePath('brands/logo.jpg'),
new Rcs('RCS PARIS 111 222 333'),
new Email('contact@example.com'),
id: BrandId::generate(),
code: $brandCode,
name: new BrandName('Brand A'),
logo: new PicturePath('brands/logo.jpg'),
rcs: new Rcs('RCS PARIS 111 222 333'),
contact: new Email('contact@example.com'),
);
$repository = \Mockery::mock(CampaignRequestRepository::class);
@ -109,20 +119,20 @@ describe('find campaign request use case', function (): void {
->and($result->rejectionReason)->toBeNull();
});
it('finds a rejected campaign request with its rejection reason', function (): void {
it('finds a rejected campaign request with its rejection reason', function () use ($requestedCoupon): void {
// GIVEN
$id = CampaignRequestId::generate();
$code = new BrandCode('AAA01');
$rejectionReason = new RejectionReason('Missing legal documents.');
$campaignRequest = CampaignRequest::hydrate(
$id,
$code,
new CampaignName('Campaign A'),
new Applicant('John', 'Doe', new Email('john.doe@example.com')),
CampaignRequestType::CLUB,
$this->requestedCoupon,
CampaignRequestStatus::REJECTED,
$rejectionReason,
id: $id,
brandCode: $code,
name: new CampaignName('Campaign A'),
applicant: new Applicant('John', 'Doe', new Email('john.doe@example.com')),
campaignType: CampaignRequestType::CLUB,
requestedCoupon: $requestedCoupon,
status: CampaignRequestStatus::REJECTED,
rejectionReason: $rejectionReason,
);
$repository = \Mockery::mock(CampaignRequestRepository::class);
@ -131,12 +141,12 @@ describe('find campaign request use case', function (): void {
$brandRepository = \Mockery::mock(BrandRepository::class);
$brandRepository->shouldReceive('findByCode')->once()->with($code)->andReturn(
Brand::hydrate(
BrandId::generate(),
$code,
new BrandName('Brand A'),
new PicturePath('brands/logo.jpg'),
new Rcs('RCS PARIS 111 222 333'),
new Email('contact@example.com'),
id: BrandId::generate(),
code: $code,
name: new BrandName('Brand A'),
logo: new PicturePath('brands/logo.jpg'),
rcs: new Rcs('RCS PARIS 111 222 333'),
contact: new Email('contact@example.com'),
)
);
@ -170,18 +180,18 @@ describe('find campaign request use case', function (): void {
expect($result)->toBeInstanceOf(CampaignRequestNotFound::class);
});
it('throws when the campaign request references a brand that no longer exists', function (): void {
it('throws when the campaign request references a brand that no longer exists', function () use ($requestedCoupon): void {
// GIVEN
$id = CampaignRequestId::generate();
$brandCode = new BrandCode('AAA01');
$campaignRequest = CampaignRequest::hydrate(
$id,
$brandCode,
new CampaignName('Campaign A'),
new Applicant('John', 'Doe', new Email('john.doe@example.com')),
CampaignRequestType::CLUB,
$this->requestedCoupon,
CampaignRequestStatus::CREATED,
id: $id,
brandCode: $brandCode,
name: new CampaignName('Campaign A'),
applicant: new Applicant('John', 'Doe', new Email('john.doe@example.com')),
campaignType: CampaignRequestType::CLUB,
requestedCoupon: $requestedCoupon,
status: CampaignRequestStatus::CREATED,
);
$repository = \Mockery::mock(CampaignRequestRepository::class);
@ -196,7 +206,7 @@ describe('find campaign request use case', function (): void {
$action = fn (): Output => $useCase->execute(new Input(id: $id));
// THEN
expect($action)->toThrow(BrandNotFoundException::class);
expect($action)->toThrow(\DomainException::class);
});
});

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Application\UseCases\CampaignRequest\List;
use DateTime;
use Domain\Enums\BarcodeFormat;
use Domain\Models\Entities\CampaignRequest;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Campaign\CampaignName;
@ -17,15 +16,20 @@ use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestSortCriteria;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestSortField;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestStatus;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestType;
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\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\CouponName;
use Domain\Models\ValueObjects\GS1;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\IncoCode;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\Period;
use Domain\Models\ValueObjects\PicturePath;
use Domain\Models\ValueObjects\PrintRules;
use Domain\Models\ValueObjects\ProcessingCode;
use Domain\Models\ValueObjects\ProcessingCodeArray;
use Domain\Models\ValueObjects\RequestedCoupon\RequestedCoupon;
use Domain\Models\ValueObjects\RequestedCoupon\RequestedCouponQuantity;
use Domain\Ports\Repositories\CampaignRequestRepository;
@ -39,69 +43,87 @@ use Shared\ValueObjects\Pagination\Pagination;
covers(ListCampaignsRequest::class, Input::class, Output::class, CampaignRequestListItem::class);
describe('list campaigns request use case', function (): void {
beforeEach(function (): void {
$this->couponA = new RequestedCoupon(
new CouponName('Coupon A'),
new RequestedCouponQuantity(100),
new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')),
new MoneyAmount(new Money(Currency::EUR, 300)),
new ProcessingCode('PC1'),
new CouponValidity(30, new DateTime('2026-02-01')),
[
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX)
],
[new IncoCode(35784), new IncoCode(15522)],
new PrintRules(maxPerCustomer: 3),
new GS1('01234567890128', BarcodeFormat::EAN13)
);
$couponA = new RequestedCoupon(
couponName: new CouponName('Coupon A'),
quantity: new RequestedCouponQuantity(100),
diffusion: new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')),
moneyAmount: new MoneyAmount(new Money(Currency::EUR, 300)),
couponPicture: new CouponPicture(
new PicturePath('files/product.jpg'),
new PicturePath('files/thumbnail.jpg'),
new PicturePath('files/mobileThumbnail.jpg')
),
couponProcessingCodes: new ProcessingCodeArray([
new ProcessingCode('5425003944130', Country::FRANCE),
new ProcessingCode('4006381333931', Country::BENELUX),
]),
couponValidityInformations: new CouponValidityInformationArray([
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX)
]),
couponLegalInformations: new CouponLegalInformationArray([
new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX)
]),
couponProducts: new CouponProductArray([new IncoCode(35784), new IncoCode(15522)]),
printRules: new PrintRules(maxPerCustomer: 3),
);
$this->couponB = new RequestedCoupon(
new CouponName('Coupon B'),
new RequestedCouponQuantity(100),
new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')),
new MoneyAmount(new Money(Currency::EUR, 300)),
new ProcessingCode('PC2'),
new CouponValidity(30, new DateTime('2026-02-01')),
[
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX)
],
[new IncoCode(24514), new IncoCode(46259)],
new PrintRules(maxPerCustomer: 3),
new GS1('01234567890128', BarcodeFormat::EAN13)
);
$couponB = new RequestedCoupon(
couponName: new CouponName('Coupon B'),
quantity: new RequestedCouponQuantity(100),
diffusion: new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')),
moneyAmount: new MoneyAmount(new Money(Currency::EUR, 300)),
couponPicture: new CouponPicture(
new PicturePath('files/product.jpg'),
new PicturePath('files/thumbnail.jpg'),
new PicturePath('files/mobileThumbnail.jpg')
),
couponProcessingCodes: new ProcessingCodeArray([
new ProcessingCode('5425003944130', Country::FRANCE),
new ProcessingCode('4006381333931', Country::BENELUX),
]),
couponValidityInformations: new CouponValidityInformationArray([
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX)
]),
couponLegalInformations: new CouponLegalInformationArray([
new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX)
]),
couponProducts: new CouponProductArray([new IncoCode(35784), new IncoCode(15522)]),
printRules: new PrintRules(maxPerCustomer: 3),
);
$this->pagination = Pagination::firstPage();
$pagination = Pagination::firstPage();
$this->sort = new CampaignRequestSortCriteria(
clauses: [
new CampaignRequestSortClause(field: CampaignRequestSortField::CreatedAt, direction: \SortDirection::Descending),
]
);
});
$sort = new CampaignRequestSortCriteria(
clauses: [
new CampaignRequestSortClause(field: CampaignRequestSortField::CreatedAt, direction: \SortDirection::Descending),
]
);
it('maps repository campaigns request to list items, preserving pagination and sort', function (): void {
it('maps repository campaigns request to list items, preserving pagination and sort', function () use ($couponA, $couponB, $pagination): void {
// GIVEN
$campaignRequest1 = CampaignRequest::hydrate(
CampaignRequestId::generate(),
new BrandCode('AAA01'),
new CampaignName('Campaign A'),
new Applicant('John', 'Doe', new Email('john.doe@example.com')),
CampaignRequestType::CLUB,
$this->couponA,
CampaignRequestStatus::CREATED,
id: CampaignRequestId::generate(),
brandCode: new BrandCode('AAA01'),
name: new CampaignName('Campaign A'),
applicant: new Applicant('John', 'Doe', new Email('john.doe@example.com')),
campaignType: CampaignRequestType::CLUB,
requestedCoupon: $couponA,
status: CampaignRequestStatus::CREATED,
);
$campaignRequest2 = CampaignRequest::hydrate(
CampaignRequestId::generate(),
new BrandCode('AAA02'),
new CampaignName('Campaign B'),
new Applicant('John', 'Doe', new Email('john.doe@example.com')),
CampaignRequestType::CLUB,
$this->couponB,
CampaignRequestStatus::CREATED,
id: CampaignRequestId::generate(),
brandCode: new BrandCode('AAA02'),
name: new CampaignName('Campaign B'),
applicant: new Applicant('John', 'Doe', new Email('john.doe@example.com')),
campaignType: CampaignRequestType::CLUB,
requestedCoupon: $couponB,
status: CampaignRequestStatus::CREATED,
);
$paginated = new Paginated($this->pagination, [$campaignRequest1, $campaignRequest2], 2);
$paginated = new Paginated($pagination, [$campaignRequest1, $campaignRequest2], 2);
$sort = new CampaignRequestSortCriteria(
clauses: [
@ -110,12 +132,12 @@ describe('list campaigns request use case', function (): void {
]
);
$filters = new CampaignRequestFilters($campaignRequest2->brandCode, CampaignRequestStatus::CREATED);
$input = new Input(pagination: $this->pagination, sort: $sort, filters: $filters);
$input = new Input(pagination: $pagination, sort: $sort, filters: $filters);
$repository = \Mockery::mock(CampaignRequestRepository::class);
$repository->shouldReceive('findAll')
->once()
->with($this->pagination, CampaignRequestSortCriteria::class, CampaignRequestFilters::class)
->with($pagination, CampaignRequestSortCriteria::class, CampaignRequestFilters::class)
->andReturn($paginated);
$useCase = new ListCampaignsRequest($repository);
@ -135,29 +157,29 @@ describe('list campaigns request use case', function (): void {
->and($items[0]->campaignType)->toBe($campaignRequest1->campaignType)
->and($items[0]->status)->toBe($campaignRequest1->status)
->and($result->campaignsRequest->getTotal())->toBe(2)
->and($result->campaignsRequest->getPagination())->toBe($this->pagination);
->and($result->campaignsRequest->getPagination())->toBe($pagination);
});
it('lists campaigns request with filters', function (): void {
it('lists campaigns request with filters', function () use ($couponA, $pagination, $sort): void {
// GIVEN
$campaignRequest = CampaignRequest::hydrate(
CampaignRequestId::generate(),
new BrandCode('AAA01'),
new CampaignName('Campaign A'),
new Applicant('John', 'Doe', new Email('john.doe@example.com')),
CampaignRequestType::CLUB,
$this->couponA,
CampaignRequestStatus::CREATED,
id: CampaignRequestId::generate(),
brandCode: new BrandCode('AAA01'),
name: new CampaignName('Campaign A'),
applicant: new Applicant('John', 'Doe', new Email('john.doe@example.com')),
campaignType: CampaignRequestType::CLUB,
requestedCoupon: $couponA,
status: CampaignRequestStatus::CREATED,
);
$paginated = new Paginated($this->pagination, [$campaignRequest], 1);
$paginated = new Paginated($pagination, [$campaignRequest], 1);
$filters = new CampaignRequestFilters($campaignRequest->brandCode, CampaignRequestStatus::CREATED);
$input = new Input(pagination: $this->pagination, sort: $this->sort, filters: $filters);
$input = new Input(pagination: $pagination, sort: $sort, filters: $filters);
$repository = \Mockery::mock(CampaignRequestRepository::class);
$repository->shouldReceive('findAll')
->once()
->with($this->pagination, $this->sort, $filters)
->with($pagination, $sort, $filters)
->andReturn($paginated);
$useCase = new ListCampaignsRequest($repository);
@ -173,10 +195,10 @@ describe('list campaigns request use case', function (): void {
->and($items[0]->name)->toBe($campaignRequest->name)
->and($items[0]->status)->toBe($campaignRequest->status)
->and($result->campaignsRequest->getTotal())->toBe(1)
->and($result->campaignsRequest->getPagination())->toBe($this->pagination);
->and($result->campaignsRequest->getPagination())->toBe($pagination);
});
it('lists campaigns request with sort but without filters', function (): void {
it('lists campaigns request with sort but without filters', function () use ($couponA, $couponB, $pagination, $sort): void {
// GIVEN
$campaignRequest1 = CampaignRequest::hydrate(
CampaignRequestId::generate(),
@ -184,7 +206,7 @@ describe('list campaigns request use case', function (): void {
new CampaignName('Campaign A'),
new Applicant('John', 'Doe', new Email('john.doe@example.com')),
CampaignRequestType::CLUB,
$this->couponA,
$couponA,
CampaignRequestStatus::CREATED,
);
$campaignRequest2 = CampaignRequest::hydrate(
@ -193,22 +215,22 @@ describe('list campaigns request use case', function (): void {
new CampaignName('Campaign B'),
new Applicant('John', 'Doe', new Email('john.doe@example.com')),
CampaignRequestType::CLUB,
$this->couponB,
$couponB,
CampaignRequestStatus::CREATED,
);
$paginated = new Paginated($this->pagination, [$campaignRequest2, $campaignRequest1], 2);
$paginated = new Paginated($pagination, [$campaignRequest2, $campaignRequest1], 2);
$sort = new CampaignRequestSortCriteria(
clauses: [
new CampaignRequestSortClause(field: CampaignRequestSortField::BrandCode, direction: \SortDirection::Descending),
]
);
$input = new Input(pagination: $this->pagination, sort: $sort);
$input = new Input(pagination: $pagination, sort: $sort);
$repository = \Mockery::mock(CampaignRequestRepository::class);
$repository->shouldReceive('findAll')
->once()
->with($this->pagination, $sort, null)
->with($pagination, $sort, null)
->andReturn($paginated);
$useCase = new ListCampaignsRequest($repository);
@ -222,18 +244,18 @@ describe('list campaigns request use case', function (): void {
->and($items[0]->brandCode)->toBe($campaignRequest2->brandCode)
->and($items[1]->brandCode)->toBe($campaignRequest1->brandCode)
->and($result->campaignsRequest->getTotal())->toBe(2)
->and($result->campaignsRequest->getPagination())->toBe($this->pagination);
->and($result->campaignsRequest->getPagination())->toBe($pagination);
});
it('returns an empty list without error when there are no campaigns request', function (): void {
it('returns an empty list without error when there are no campaigns request', function () use ($pagination, $sort): void {
// GIVEN
$paginated = new Paginated($this->pagination, [], 0);
$input = new Input(pagination: $this->pagination, sort: $this->sort);
$paginated = new Paginated($pagination, [], 0);
$input = new Input(pagination: $pagination, sort: $sort);
$repository = \Mockery::mock(CampaignRequestRepository::class);
$repository->shouldReceive('findAll')
->once()
->with($this->pagination, $this->sort, null)
->with($pagination, $sort, null)
->andReturn($paginated);
$useCase = new ListCampaignsRequest($repository);

View file

@ -11,7 +11,6 @@ use Application\UseCases\CampaignRequest\Reject\Output\Output;
use Application\UseCases\CampaignRequest\Reject\RejectCampaignRequest;
use Carbon\CarbonImmutable;
use DateTime;
use Domain\Enums\BarcodeFormat;
use Domain\Models\Entities\CampaignRequest;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Campaign\CampaignName;
@ -20,15 +19,20 @@ use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestId;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestStatus;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestType;
use Domain\Models\ValueObjects\CampaignRequest\RejectionReason;
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\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\CouponName;
use Domain\Models\ValueObjects\GS1;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\IncoCode;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\Period;
use Domain\Models\ValueObjects\PicturePath;
use Domain\Models\ValueObjects\PrintRules;
use Domain\Models\ValueObjects\ProcessingCode;
use Domain\Models\ValueObjects\ProcessingCodeArray;
use Domain\Models\ValueObjects\RequestedCoupon\RequestedCoupon;
use Domain\Models\ValueObjects\RequestedCoupon\RequestedCouponQuantity;
use Domain\Ports\Repositories\CampaignRequestRepository;
@ -40,31 +44,39 @@ use Shared\ValueObjects\Money;
covers(RejectCampaignRequest::class, Input::class, CampaignRequestRejected::class, CampaignRequestNotFound::class);
describe('reject campaign request use case', function (): void {
beforeEach(function (): void {
$this->requestedCoupon = new RequestedCoupon(
new CouponName('Coupon A'),
new RequestedCouponQuantity(100),
new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')),
new MoneyAmount(new Money(Currency::EUR, 300)),
new ProcessingCode('PC1'),
new CouponValidity(30, new DateTime('2026-02-01')),
[
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX)
],
[new IncoCode(35784), new IncoCode(15522)],
new PrintRules(maxPerCustomer: 3),
new GS1('01234567890128', BarcodeFormat::EAN13)
);
});
$requestedCoupon = new RequestedCoupon(
couponName: new CouponName('Coupon A'),
quantity: new RequestedCouponQuantity(100),
diffusion: new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')),
moneyAmount: new MoneyAmount(new Money(Currency::EUR, 300)),
couponPicture: new CouponPicture(
new PicturePath('files/product.jpg'),
new PicturePath('files/thumbnail.jpg'),
new PicturePath('files/mobileThumbnail.jpg')
),
couponProcessingCodes: new ProcessingCodeArray([
new ProcessingCode('5425003944130', Country::FRANCE),
new ProcessingCode('4006381333931', Country::BENELUX),
]),
couponValidityInformations: new CouponValidityInformationArray([
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX)
]),
couponLegalInformations: new CouponLegalInformationArray([
new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX)
]),
couponProducts: new CouponProductArray([new IncoCode(35784), new IncoCode(15522)]),
printRules: new PrintRules(maxPerCustomer: 3),
);
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
it('rejects a campaign request and persists it', function (): void {
it('rejects a campaign request and persists it', function () use ($requestedCoupon): 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,
@ -72,7 +84,7 @@ describe('reject campaign request use case', function (): void {
new CampaignName('Campaign A'),
new Applicant('John', 'Doe', new Email('john.doe@example.com')),
CampaignRequestType::CLUB,
$this->requestedCoupon,
$requestedCoupon,
CampaignRequestStatus::CREATED,
);
$reason = new RejectionReason('Missing legal documents.');
@ -81,8 +93,7 @@ describe('reject campaign request use case', function (): void {
$repository->shouldReceive('findById')->once()->with($id)->andReturn($campaignRequest);
$repository->shouldReceive('save')->once()->with(\Mockery::on(
fn (CampaignRequest $saved): bool => $saved->status === CampaignRequestStatus::REJECTED
&& $saved->rejectionReason === $reason
&& $saved->updatedAt !== null,
&& $saved->rejectionReason === $reason,
));
$useCase = new RejectCampaignRequest($repository);
@ -94,8 +105,7 @@ describe('reject campaign request use case', function (): void {
expect($result)->toBeInstanceOf(CampaignRequestRejected::class)
->and($result->id)->toBe($id)
->and($result->status)->toBe(CampaignRequestStatus::REJECTED)
->and($result->rejectionReason)->toBe($reason)
->and($result->updatedAt->format('Y-m-d H:i:s'))->toBe('2026-06-15 12:00:00');
->and($result->rejectionReason)->toBe($reason);
});
it('returns CampaignRequestNotFound when the id is not found', function (): void {
@ -115,7 +125,7 @@ describe('reject campaign request use case', function (): void {
expect($result)->toBeInstanceOf(CampaignRequestNotFound::class);
});
it('propagates the domain exception when the campaign request cannot be rejected', function (): void {
it('propagates the domain exception when the campaign request cannot be rejected', function () use ($requestedCoupon): void {
// GIVEN
$id = CampaignRequestId::generate();
$campaignRequest = CampaignRequest::hydrate(
@ -124,7 +134,7 @@ describe('reject campaign request use case', function (): void {
new CampaignName('Campaign A'),
new Applicant('John', 'Doe', new Email('john.doe@example.com')),
CampaignRequestType::CLUB,
$this->requestedCoupon,
$requestedCoupon,
CampaignRequestStatus::TRANSFORMED,
);
@ -142,19 +152,4 @@ describe('reject campaign request use case', function (): void {
// THEN
expect($action)->toThrow(\DomainException::class, 'Only a created campaign request can be rejected.');
});
it('never touches the repository when a rejection reason is missing', function (): void {
// GIVEN
$repository = \Mockery::mock(CampaignRequestRepository::class);
$repository->shouldNotReceive('findById');
$repository->shouldNotReceive('save');
$useCase = new RejectCampaignRequest($repository);
// WHEN
$action = fn (): Output => $useCase->execute(new Input(id: CampaignRequestId::generate()));
// THEN
expect($action)->toThrow(\ArgumentCountError::class);
});
});

View file

@ -0,0 +1,343 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\UseCases\Coupon\Create;
use Application\UseCases\Coupon\Create\CreateCoupon;
use Application\UseCases\Coupon\Create\Input;
use Application\UseCases\Coupon\Create\Output;
use DateTime;
use Domain\Models\Entities\Coupon;
use Domain\Models\ValueObjects\Actor\ActorId;
use Domain\Models\ValueObjects\Brand\BrandCode;
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\CouponLegalInformationArray;
use Domain\Models\ValueObjects\Coupon\CouponMovement;
use Domain\Models\ValueObjects\Coupon\CouponMovementArray;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\Coupon\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\Period;
use Domain\Models\ValueObjects\PicturePath;
use Domain\Models\ValueObjects\ProcessingCode;
use Domain\Models\ValueObjects\ProcessingCodeArray;
use Domain\Ports\Repositories\BrandRepository;
use Domain\Ports\Repositories\CouponRepository;
use Shared\DDD\UseCases\Input as BaseInput;
use Shared\Enums\Country;
use Shared\Enums\Currency;
use Shared\ValueObjects\Money;
covers(CreateCoupon::class, Input::class, Output::class);
describe('create coupon use case', function (): void {
$makeInput = fn (array $overrides = []): Input => new Input(...array_merge([
'code' => new CouponCode('COU01'),
'name' => new CouponName('Coupon name'),
'amount' => new MoneyAmount(new Money(Currency::EUR, 100)),
'picture' => new CouponPicture(
new PicturePath('files/product.jpg'),
new PicturePath('files/thumbnail.jpg'),
new PicturePath('files/mobileThumbnail.jpg'),
),
'diffusion' => new CouponDiffusion(
new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')),
),
'validityInformations' => new CouponValidityInformationArray([
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
]),
'legalInformations' => new CouponLegalInformationArray([
new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
]),
'processingCode' => new ProcessingCodeArray([
new ProcessingCode('5425003944130', Country::FRANCE),
]),
'brandCode' => new BrandCode('TST89'),
'createdBy' => ActorId::generate(),
'products' => new CouponProductArray(),
'movements' => new CouponMovementArray([
new CouponMovement(100),
]),
'availabilityCalendar' => null,
], $overrides));
it('creates a coupon when the brand exists and the code is unique', function () use ($makeInput): void {
// GIVEN
$input = $makeInput();
$brandRepository = \Mockery::mock(BrandRepository::class);
$brandRepository->shouldReceive('existsByCode')->once()->with($input->brandCode)->andReturn(true);
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('existsByCode')->once()->with($input->code)->andReturn(false);
$couponRepository->shouldReceive('save')->once()->with(\Mockery::type(Coupon::class));
$useCase = new CreateCoupon($couponRepository, $brandRepository);
// WHEN
$result = $useCase->execute($input);
// THEN
expect($result)->toBeInstanceOf(Output::class)
->and($result->id)->toBeInstanceOf(CouponId::class)
->and($result->code)->toEqual($input->code)
->and($result->name)->toEqual($input->name)
->and($result->amount)->toEqual($input->amount)
->and($result->picture)->toEqual($input->picture)
->and($result->diffusion)->toEqual($input->diffusion)
->and($result->validityInformations)->toEqual($input->validityInformations)
->and($result->legalInformations)->toEqual($input->legalInformations)
->and($result->processingCode)->toEqual($input->processingCode)
->and($result->brandCode)->toEqual($input->brandCode)
->and($result->createdBy)->toEqual($input->createdBy)
->and($result->products)->toEqual($input->products)
->and($result->movements)->toEqual($input->movements)
->and($result->availabilityCalendar)->toBeNull();
});
it('throws when the brand does not exist', function () use ($makeInput): void {
// GIVEN
$input = $makeInput();
$brandRepository = \Mockery::mock(BrandRepository::class);
$brandRepository->shouldReceive('existsByCode')->once()->with($input->brandCode)->andReturn(false);
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldNotReceive('existsByCode');
$couponRepository->shouldNotReceive('save');
$useCase = new CreateCoupon($couponRepository, $brandRepository);
// WHEN
$action = fn (): Output => $useCase->execute($input);
// THEN
expect($action)->toThrow(\DomainException::class, 'Brand code "TST89" was not found.');
});
it('throws when the coupon code already exists', function () use ($makeInput): void {
// GIVEN
$input = $makeInput();
$brandRepository = \Mockery::mock(BrandRepository::class);
$brandRepository->shouldReceive('existsByCode')->once()->with($input->brandCode)->andReturn(true);
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('existsByCode')->once()->with($input->code)->andReturn(true);
$couponRepository->shouldNotReceive('save');
$useCase = new CreateCoupon($couponRepository, $brandRepository);
// WHEN
$action = fn (): Output => $useCase->execute($input);
// THEN
expect($action)->toThrow(\DomainException::class, 'Coupon code "COU01" already exists.');
});
it('creates a coupon with an availability calendar', function () use ($makeInput): void {
// GIVEN
$calendar = new CouponAvailabilityCalendar([
new Period(new DateTime('2026-01-05'), new DateTime('2026-01-15')),
new Period(new DateTime('2026-01-20'), new DateTime('2026-01-25')),
]);
$input = $makeInput(['availabilityCalendar' => $calendar]);
$brandRepository = \Mockery::mock(BrandRepository::class);
$brandRepository->shouldReceive('existsByCode')->once()->andReturn(true);
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('existsByCode')->once()->andReturn(false);
$couponRepository->shouldReceive('save')->once()->with(\Mockery::on(
fn (Coupon $saved): bool => $saved->availabilityCalendar === $calendar,
));
$useCase = new CreateCoupon($couponRepository, $brandRepository);
// WHEN
$result = $useCase->execute($input);
// THEN
expect($result->availabilityCalendar)->toBe($calendar);
});
it('creates a GS1 coupon with France and Benelux', function () use ($makeInput): void {
// GIVEN
$processingCode = new ProcessingCodeArray([
new ProcessingCode('5425003944130', Country::FRANCE),
new ProcessingCode('5425003944147', Country::BENELUX),
]);
$validityInformations = new CouponValidityInformationArray([
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX),
]);
$legalInformations = new CouponLegalInformationArray([
new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX),
]);
$input = $makeInput([
'processingCode' => $processingCode,
'validityInformations' => $validityInformations,
'legalInformations' => $legalInformations,
]);
$brandRepository = \Mockery::mock(BrandRepository::class);
$brandRepository->shouldReceive('existsByCode')->once()->andReturn(true);
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('existsByCode')->once()->andReturn(false);
$couponRepository->shouldReceive('save')->once()->with(\Mockery::type(Coupon::class));
$useCase = new CreateCoupon($couponRepository, $brandRepository);
// WHEN
$result = $useCase->execute($input);
// THEN
expect($result->processingCode)->toBe($processingCode)
->and($result->validityInformations)->toBe($validityInformations)
->and($result->legalInformations)->toBe($legalInformations);
});
it('rejects a coupon that targets only benelux', function () use ($makeInput): void {
// GIVEN / WHEN
$action = fn (): Input => $makeInput([
'processingCode' => new ProcessingCodeArray([
new ProcessingCode('5425003944147', Country::BENELUX),
]),
'validityInformations' => new CouponValidityInformationArray([
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX),
]),
'legalInformations' => new CouponLegalInformationArray([
new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX),
]),
]);
// THEN
expect($action)->toThrow(\DomainException::class);
});
it('rejects a benelux processing code without benelux legal information', function () use ($makeInput): void {
// GIVEN
$input = $makeInput([
'processingCode' => new ProcessingCodeArray([
new ProcessingCode('5425003944130', Country::FRANCE),
new ProcessingCode('5425003944147', Country::BENELUX),
]),
'validityInformations' => new CouponValidityInformationArray([
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX),
]),
]);
$brandRepository = \Mockery::mock(BrandRepository::class);
$brandRepository->shouldReceive('existsByCode')->andReturn(true);
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('existsByCode')->andReturn(false);
$couponRepository->shouldNotReceive('save');
$useCase = new CreateCoupon($couponRepository, $brandRepository);
// WHEN
$action = fn (): Output => $useCase->execute($input);
// THEN
expect($action)->toThrow(\DomainException::class, 'A coupon requires legal information for BENELUX.');
});
it('rejects a benelux processing code without benelux validity information', function () use ($makeInput): void {
// GIVEN
$input = $makeInput([
'processingCode' => new ProcessingCodeArray([
new ProcessingCode('5425003944130', Country::FRANCE),
new ProcessingCode('5425003944147', Country::BENELUX),
]),
'legalInformations' => new CouponLegalInformationArray([
new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX),
]),
]);
$brandRepository = \Mockery::mock(BrandRepository::class);
$brandRepository->shouldReceive('existsByCode')->andReturn(true);
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('existsByCode')->andReturn(false);
$couponRepository->shouldNotReceive('save');
$useCase = new CreateCoupon($couponRepository, $brandRepository);
// WHEN
$action = fn (): Output => $useCase->execute($input);
// THEN
expect($action)->toThrow(\DomainException::class, 'A coupon requires validity information for BENELUX.');
});
it('rejects two processing codes for the same country', function () use ($makeInput): void {
// GIVEN / WHEN
$action = fn (): Input => $makeInput([
'processingCode' => new ProcessingCodeArray([
new ProcessingCode('5425003944130', Country::FRANCE),
new ProcessingCode('5425003944147', Country::FRANCE),
]),
]);
// THEN
expect($action)->toThrow(\DomainException::class);
});
it('rejects two legal informations for the same country', function () use ($makeInput): void {
// GIVEN / WHEN
$action = fn (): Input => $makeInput([
'legalInformations' => new CouponLegalInformationArray([
new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
]),
]);
// THEN
expect($action)->toThrow(\DomainException::class);
});
it('rejects two validity informations for the same country', function () use ($makeInput): void {
// GIVEN / WHEN
$action = fn (): Input => $makeInput([
'validityInformations' => new CouponValidityInformationArray([
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
]),
]);
// THEN
expect($action)->toThrow(\DomainException::class);
});
});
describe('create coupon use case assertions', function (): void {
it('rejects an input of the wrong type', function (): void {
// GIVEN
$useCase = new CreateCoupon(
\Mockery::mock(CouponRepository::class),
\Mockery::mock(BrandRepository::class),
);
$invalidInput = new class () implements BaseInput
{
};
// WHEN
$action = fn (): Output => $useCase->execute($invalidInput);
// THEN
expect($action)->toThrow(\InvalidArgumentException::class);
});
});

View file

@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\UseCases\Coupon\Find;
use Application\UseCases\Coupon\Find\FindCoupon;
use Application\UseCases\Coupon\Find\Input;
use Application\UseCases\Coupon\Find\Output\CouponFound;
use Application\UseCases\Coupon\Find\Output\CouponNotFound;
use Application\UseCases\Coupon\Find\Output\Output;
use DateTime;
use Domain\Models\Entities\Brand;
use Domain\Models\Entities\Campaign;
use Domain\Models\Entities\Coupon;
use Domain\Models\ValueObjects\Actor\ActorId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandName;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\Campaign\CampaignCoupon;
use Domain\Models\ValueObjects\Campaign\CampaignCouponArray;
use Domain\Models\ValueObjects\Campaign\CampaignName;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Coupon\CouponDiffusion;
use Domain\Models\ValueObjects\Coupon\CouponLegalInformation;
use Domain\Models\ValueObjects\Coupon\CouponLegalInformationArray;
use Domain\Models\ValueObjects\Coupon\CouponMovement;
use Domain\Models\ValueObjects\Coupon\CouponMovementArray;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\Coupon\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\IncoCode;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\Period;
use Domain\Models\ValueObjects\PicturePath;
use Domain\Models\ValueObjects\ProcessingCode;
use Domain\Models\ValueObjects\ProcessingCodeArray;
use Domain\Ports\Repositories\BrandRepository;
use Domain\Ports\Repositories\CampaignRepository;
use Domain\Ports\Repositories\CouponRepository;
use Shared\Enums\Country;
use Shared\Enums\Currency;
use Shared\ValueObjects\Email;
use Shared\ValueObjects\Money;
use Shared\ValueObjects\Rcs;
covers(FindCoupon::class, Input::class, CouponFound::class, CouponNotFound::class);
describe('find coupon use case', function (): void {
$brand = Brand::create(
code: new BrandCode('AAA01'),
name: new BrandName('Name'),
logo: new PicturePath('files/picture.jpg'),
rcs: new Rcs('RCS PARIS 111 222 333'),
contact: new Email('contact@example.com'),
);
$coupon = Coupon::create(
code: new CouponCode('COU01'),
name: new CouponName('Coupon name'),
amount: new MoneyAmount(new Money(Currency::EUR, 100)),
picture: new CouponPicture(
new PicturePath('files/product.jpg'),
new PicturePath('files/thumbnail.jpg'),
new PicturePath('files/mobile_thumbnail.jpg'),
),
diffusion: new CouponDiffusion(
new Period(
new DateTime('2026-01-01'),
new DateTime('2026-02-01'),
),
),
validityInformations: new CouponValidityInformationArray([
new CouponValidityInformation(
'Lorem ipsum dolor sit amet consectetur adipiscing elit.',
Country::FRANCE
),
]),
legalInformations: new CouponLegalInformationArray([
new CouponLegalInformation(
'Lorem ipsum dolor sit amet consectetur adipiscing elit.',
Country::FRANCE
),
]),
processingCode: new ProcessingCodeArray([
new ProcessingCode('5425003944130', Country::FRANCE),
]),
brandCode: $brand->code,
createdBy: ActorId::generate(),
products: new CouponProductArray([
new IncoCode(35784),
new IncoCode(15522),
]),
movements: new CouponMovementArray([
new CouponMovement(1000)
]),
);
$campaign = Campaign::create(
code: new CampaignCode('CMP01'),
name: new CampaignName('Campaign name'),
publication: true,
printAllAtOnce: true,
coupons: new CampaignCouponArray([
new CampaignCoupon($coupon->code),
]),
brandCode: $brand->code,
actorId: ActorId::generate(),
);
it('finds a coupon by its code', function () use ($coupon, $campaign): void {
// GIVEN
$brandRepository = \Mockery::mock(BrandRepository::class);
$brandRepository->shouldReceive('existsByCode')->once()->with($coupon->brandCode)->andReturnTrue();
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon);
$campaignRepository = \Mockery::mock(CampaignRepository::class);
$campaignRepository->shouldReceive('findByCouponCode')->once()->with($coupon->code)->andReturn([$campaign]);
$useCase = new FindCoupon($couponRepository, $brandRepository, $campaignRepository);
// WHEN
$result = $useCase->execute(new Input(code: $coupon->code));
// THEN
expect($result)->toBeInstanceOf(CouponFound::class)
->and($result->code)->toBe($coupon->code)
->and($result->name)->toBe($coupon->name)
->and($result->amount)->toBe($coupon->amount)
->and($result->picture)->toBe($coupon->picture)
->and($result->diffusion)->toBe($coupon->diffusion)
->and($result->availabilityCalendar)->toBe($coupon->availabilityCalendar)
->and($result->validityInformations)->toBe($coupon->validityInformations)
->and($result->legalInformations)->toBe($coupon->legalInformations)
->and($result->processingCode)->toBe($coupon->processingCode)
->and($result->brandCode)->toBe($coupon->brandCode)
->and($result->createdBy)->toBe($coupon->createdBy)
->and($result->products)->toBe($coupon->products)
->and($result->movements)->toBe($coupon->movements)
->and($result->quantity)->toBe($coupon->quantity())
->and($result->campaigns)->toBe([$campaign])
->and($result->createdAt)->toBeInstanceOf(\DateTimeInterface::class)
->and($result->updatedAt)->toBeNull();
});
it('returns CouponNotFound when the code is not found', function (): void {
// GIVEN
$code = new CouponCode('COU02');
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')->once()->with($code)->andReturn(null);
$brandRepository = \Mockery::mock(BrandRepository::class);
$brandRepository->shouldNotReceive('existsByCode');
$campaignRepository = \Mockery::mock(CampaignRepository::class);
$campaignRepository->shouldNotReceive('findByCouponCode');
$useCase = new FindCoupon($couponRepository, $brandRepository, $campaignRepository);
// WHEN
$result = $useCase->execute(new Input(code: $code));
// THEN
expect($result)->toBeInstanceOf(CouponNotFound::class);
});
it('throws when the brand of the coupon is not found', function () use ($coupon): void {
// GIVEN
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon);
$brandRepository = \Mockery::mock(BrandRepository::class);
$brandRepository->shouldReceive('existsByCode')->once()->with($coupon->brandCode)->andReturnFalse();
$campaignRepository = \Mockery::mock(CampaignRepository::class);
$campaignRepository->shouldNotReceive('findByCouponCode');
$useCase = new FindCoupon($couponRepository, $brandRepository, $campaignRepository);
// WHEN / THEN
expect(fn (): Output => $useCase->execute(new Input(code: $coupon->code)))
->toThrow(\DomainException::class);
});
it('finds a coupon not attached to any campaign', function () use ($coupon): void {
// GIVEN
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon);
$brandRepository = \Mockery::mock(BrandRepository::class);
$brandRepository->shouldReceive('existsByCode')->once()->with($coupon->brandCode)->andReturnTrue();
$campaignRepository = \Mockery::mock(CampaignRepository::class);
$campaignRepository->shouldReceive('findByCouponCode')->once()->with($coupon->code)->andReturn([]);
$useCase = new FindCoupon($couponRepository, $brandRepository, $campaignRepository);
// WHEN
$result = $useCase->execute(new Input(code: $coupon->code));
// THEN
expect($result)->toBeInstanceOf(CouponFound::class)
->and($result->campaigns)->toBe([]);
});
});

View file

@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\UseCases\Coupon\List;
use Application\UseCases\Coupon\List\CouponListItem;
use Application\UseCases\Coupon\List\Input;
use Application\UseCases\Coupon\List\ListCoupons;
use Application\UseCases\Coupon\List\Output;
use DateTime;
use Domain\Models\Entities\Coupon;
use Domain\Models\ValueObjects\Actor\ActorId;
use Domain\Models\ValueObjects\Brand\BrandCode;
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\CouponLegalInformationArray;
use Domain\Models\ValueObjects\Coupon\CouponMovement;
use Domain\Models\ValueObjects\Coupon\CouponMovementArray;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\Coupon\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponSortCriteria;
use Domain\Models\ValueObjects\Coupon\CouponSortField;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\IncoCode;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\Period;
use Domain\Models\ValueObjects\PicturePath;
use Domain\Models\ValueObjects\ProcessingCode;
use Domain\Models\ValueObjects\ProcessingCodeArray;
use Domain\Ports\Repositories\CouponRepository;
use Shared\DDD\UseCases\Input as BaseInput;
use Shared\Enums\Country;
use Shared\Enums\Currency;
use Shared\ValueObjects\Money;
use Shared\ValueObjects\Pagination\Paginated;
use Shared\ValueObjects\Pagination\Pagination;
covers(ListCoupons::class, Input::class, Output::class, CouponListItem::class);
describe('list coupons use case', function (): void {
$coupon1 = Coupon::hydrate(
id: CouponId::generate(),
code: new CouponCode('AAA01'),
name: new CouponName('Coupon 1'),
amount: new MoneyAmount(new Money(Currency::EUR, 300)),
picture: new CouponPicture(
new PicturePath('files/product-1.jpg'),
new PicturePath('files/thumbnail-1.jpg'),
new PicturePath('files/mobileThumbnail-1.jpg')
),
diffusion: new CouponDiffusion(new Period(
new DateTime('2026-01-01'),
new DateTime('2026-02-01'),
)),
validityInformations: new CouponValidityInformationArray([
new CouponValidityInformation('Lorem ipsum dolor sit amet.', Country::FRANCE),
]),
legalInformations: new CouponLegalInformationArray([
new CouponLegalInformation('Lorem ipsum dolor sit amet.', Country::FRANCE),
]),
processingCode: new ProcessingCodeArray([
new ProcessingCode('5425003944130', Country::FRANCE),
]),
brandCode: new BrandCode('AAA01'),
createdBy: ActorId::generate(),
products: new CouponProductArray([new IncoCode(35784)]),
movements: new CouponMovementArray([new CouponMovement(100)]),
availabilityCalendar: new CouponAvailabilityCalendar([
new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')),
]),
);
$coupon2 = Coupon::hydrate(
id: CouponId::generate(),
code: new CouponCode('BBB02'),
name: new CouponName('Coupon 2'),
amount: new MoneyAmount(new Money(Currency::EUR, 300)),
picture: new CouponPicture(
new PicturePath('files/product-2.jpg'),
new PicturePath('files/thumbnail-2.jpg'),
new PicturePath('files/mobileThumbnail-2.jpg')
),
diffusion: new CouponDiffusion(new Period(
new DateTime('2027-01-01'),
new DateTime('2027-02-01'),
)),
validityInformations: new CouponValidityInformationArray([
new CouponValidityInformation('Lorem ipsum dolor sit amet.', Country::FRANCE),
]),
legalInformations: new CouponLegalInformationArray([
new CouponLegalInformation('Lorem ipsum dolor sit amet.', Country::FRANCE),
]),
processingCode: new ProcessingCodeArray([
new ProcessingCode('5425003944147', Country::FRANCE),
]),
brandCode: new BrandCode('BBB02'),
createdBy: ActorId::generate(),
products: new CouponProductArray([new IncoCode(35784)]),
movements: new CouponMovementArray([new CouponMovement(100)]),
);
$pagination = Pagination::firstPage();
$brandCode = new BrandCode('AAA01');
it('maps repository coupons to list items, preserving pagination and sort', function () use (
$coupon1,
$coupon2,
$pagination,
$brandCode,
): void {
// GIVEN
$paginated = new Paginated($pagination, [$coupon1, $coupon2], 2);
$sort = new CouponSortCriteria();
$input = new Input(pagination: $pagination, sort: $sort, brandCode: $brandCode);
$repository = \Mockery::mock(CouponRepository::class);
$repository->shouldReceive('findAll')
->once()
->with($pagination, CouponSortCriteria::class, $brandCode)
->andReturn($paginated);
$useCase = new ListCoupons($repository);
// WHEN
$result = $useCase->execute($input);
// THEN
expect($result)->toBeInstanceOf(Output::class);
$items = $result->coupons->getItems();
expect($items)->toHaveCount(2)
->and($items[0])->toBeInstanceOf(CouponListItem::class)
->and($items[0]->code)->toBe($coupon1->code)
->and($items[0]->name)->toBe($coupon1->name)
->and($items[0]->brandCode)->toBe($coupon1->brandCode)
->and($items[0]->picture)->toBe($coupon1->picture)
->and($items[0]->diffusion)->toBe($coupon1->diffusion)
->and($items[0]->availabilityCalendar)->toBe($coupon1->availabilityCalendar)
->and($items[1]->code)->toBe($coupon2->code)
->and($items[1]->brandCode)->toBe($coupon2->brandCode)
->and($items[1]->availabilityCalendar)->toBeNull()
->and($result->coupons->getTotal())->toBe(2)
->and($result->coupons->getPagination())->toBe($pagination);
});
it('maps repository coupons to list items, preserving specific pagination and sort', function () use (
$coupon1,
$coupon2,
$pagination,
): void {
// GIVEN
$paginated = new Paginated($pagination, [$coupon2, $coupon1], 2);
$sort = new CouponSortCriteria(field: CouponSortField::BrandCode, direction: \SortDirection::Descending);
$input = new Input(pagination: $pagination, sort: $sort);
$repository = \Mockery::mock(CouponRepository::class);
$repository->shouldReceive('findAll')
->once()
->with($pagination, CouponSortCriteria::class, null)
->andReturn($paginated);
$useCase = new ListCoupons($repository);
// WHEN
$result = $useCase->execute($input);
// THEN
expect($result)->toBeInstanceOf(Output::class);
$items = $result->coupons->getItems();
expect($items)->toHaveCount(2)
->and($items[1])->toBeInstanceOf(CouponListItem::class)
->and($items[1]->code)->toBe($coupon1->code)
->and($items[1]->name)->toBe($coupon1->name)
->and($items[1]->brandCode)->toBe($coupon1->brandCode)
->and($items[1]->picture)->toBe($coupon1->picture)
->and($items[1]->diffusion)->toBe($coupon1->diffusion)
->and($items[1]->availabilityCalendar)->toBe($coupon1->availabilityCalendar)
->and($items[0]->code)->toBe($coupon2->code)
->and($items[0]->brandCode)->toBe($coupon2->brandCode)
->and($items[0]->availabilityCalendar)->toBeNull()
->and($result->coupons->getTotal())->toBe(2)
->and($result->coupons->getPagination())->toBe($pagination);
});
it('returns an empty list without error when there are no coupons', function (): void {
// GIVEN
$pagination = Pagination::firstPage();
$paginated = new Paginated($pagination, [], 0);
$input = new Input(pagination: $pagination);
$repository = \Mockery::mock(CouponRepository::class);
$repository->shouldReceive('findAll')
->once()
->with($pagination, null, null)
->andReturn($paginated);
$useCase = new ListCoupons($repository);
// WHEN
$result = $useCase->execute($input);
// THEN
expect($result->coupons->getItems())->toBe([])
->and($result->coupons->getTotal())->toBe(0);
});
});
describe('list coupons use case assertions', function (): void {
it('rejects an input of the wrong type', function (): void {
// GIVEN
$repository = \Mockery::mock(CouponRepository::class);
$useCase = new ListCoupons($repository);
$invalidInput = new class () implements BaseInput
{
};
// WHEN
$action = fn (): Output => $useCase->execute($invalidInput);
// THEN
expect($action)->toThrow(\InvalidArgumentException::class);
});
});

View file

@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\UseCases\Coupon\Update;
use Application\UseCases\Coupon\Update\Input;
use Application\UseCases\Coupon\Update\Output\CouponNotFound;
use Application\UseCases\Coupon\Update\Output\CouponUpdated;
use Application\UseCases\Coupon\Update\UpdateCoupon;
use DateTime;
use Domain\Models\Entities\Coupon;
use Domain\Models\ValueObjects\Actor\ActorId;
use Domain\Models\ValueObjects\Brand\BrandCode;
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\CouponLegalInformationArray;
use Domain\Models\ValueObjects\Coupon\CouponMovement;
use Domain\Models\ValueObjects\Coupon\CouponMovementArray;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\Coupon\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\IncoCode;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\Period;
use Domain\Models\ValueObjects\PicturePath;
use Domain\Models\ValueObjects\ProcessingCode;
use Domain\Models\ValueObjects\ProcessingCodeArray;
use Domain\Ports\Repositories\CouponRepository;
use Shared\Enums\Country;
use Shared\Enums\Currency;
use Shared\ValueObjects\Money;
covers(UpdateCoupon::class, Input::class, CouponUpdated::class, CouponNotFound::class);
describe('update coupon use case', function (): void {
$makeCoupon = static fn (): Coupon => Coupon::hydrate(
id: CouponId::generate(),
code: new CouponCode('COU01'),
name: new CouponName('Coupon name'),
amount: new MoneyAmount(new Money(Currency::EUR, 100)),
picture: new CouponPicture(
new PicturePath('files/decoration-old.jpg'),
new PicturePath('files/thumbnail-old.jpg'),
new PicturePath('files/mobile-thumbnail-old.jpg'),
),
diffusion: new CouponDiffusion(
new Period(
new DateTime('2026-01-01'),
new DateTime('2026-02-01'),
),
),
validityInformations: new CouponValidityInformationArray([
new CouponValidityInformation(
'Lorem ipsum dolor sit amet consectetur adipiscing elit.',
Country::FRANCE,
),
]),
legalInformations: new CouponLegalInformationArray([
new CouponLegalInformation(
'Lorem ipsum dolor sit amet consectetur adipiscing elit.',
Country::FRANCE,
),
]),
processingCode: new ProcessingCodeArray([
new ProcessingCode('5425003944130', Country::FRANCE),
]),
brandCode: new BrandCode('AAA01'),
createdBy: ActorId::generate(),
products: new CouponProductArray([
new IncoCode(35784),
new IncoCode(15522),
]),
movements: new CouponMovementArray([
new CouponMovement(1000),
]),
);
it('updates the picture and keeps the other fields', function () use ($makeCoupon): void {
// GIVEN
$coupon = $makeCoupon();
$newPicture = new CouponPicture(
new PicturePath('files/decoration-new.jpg'),
new PicturePath('files/thumbnail-new.jpg'),
new PicturePath('files/mobile-thumbnail-new.jpg'),
);
$input = new Input(code: $coupon->code, picture: $newPicture);
$repository = \Mockery::mock(CouponRepository::class);
$repository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon);
$repository->shouldReceive('save')->once()->with(\Mockery::on(
fn (Coupon $saved): bool => $saved->picture === $newPicture
&& $saved->id === $coupon->id
&& $saved->code === $coupon->code
&& $saved->name === $coupon->name
&& $saved->amount === $coupon->amount
&& $saved->diffusion === $coupon->diffusion
&& $saved->updatedAt instanceof \DateTimeInterface,
));
$useCase = new UpdateCoupon($repository);
// WHEN
$result = $useCase->execute($input);
// THEN
expect($result)->toBeInstanceOf(CouponUpdated::class)
->and($result->code)->toBe($coupon->code)
->and($result->picture)->toBe($newPicture);
});
it('returns CouponNotFound when the coupon does not exist', function (): void {
// GIVEN
$code = new CouponCode('COU01');
$input = new Input(
code: $code,
picture: new CouponPicture(
new PicturePath('files/decoration-new.jpg'),
new PicturePath('files/thumbnail-new.jpg'),
new PicturePath('files/mobile-thumbnail-new.jpg'),
),
);
$repository = \Mockery::mock(CouponRepository::class);
$repository->shouldReceive('findByCode')->once()->with($code)->andReturn(null);
$repository->shouldNotReceive('save');
$useCase = new UpdateCoupon($repository);
// WHEN
$result = $useCase->execute($input);
// THEN
expect($result)->toBeInstanceOf(CouponNotFound::class);
});
it('updates the legal and validity information and keeps the other fields', function () use ($makeCoupon): void {
// GIVEN
$coupon = $makeCoupon();
$newValidityInformations = new CouponValidityInformationArray([
new CouponValidityInformation('New validity information.', Country::FRANCE),
]);
$newLegalInformations = new CouponLegalInformationArray([
new CouponLegalInformation('New legal information.', Country::FRANCE),
]);
$input = new Input(
code: $coupon->code,
validityInformations: $newValidityInformations,
legalInformations: $newLegalInformations,
);
$repository = \Mockery::mock(CouponRepository::class);
$repository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon);
$repository->shouldReceive('save')->once()->with(\Mockery::on(
fn (Coupon $saved): bool => $saved->validityInformations === $newValidityInformations
&& $saved->legalInformations === $newLegalInformations
&& $saved->id === $coupon->id
&& $saved->name === $coupon->name
&& $saved->amount === $coupon->amount
&& $saved->picture === $coupon->picture
&& $saved->diffusion === $coupon->diffusion
&& $saved->movements === $coupon->movements
&& $saved->updatedAt instanceof \DateTimeInterface,
));
$useCase = new UpdateCoupon($repository);
// WHEN
$result = $useCase->execute($input);
// THEN
expect($result)->toBeInstanceOf(CouponUpdated::class)
->and($result->validityInformations)->toBe($newValidityInformations)
->and($result->legalInformations)->toBe($newLegalInformations);
});
it('rejects an input with nothing to update', function (): void {
// GIVEN
$code = new CouponCode('COU01');
// WHEN
$action = fn (): Input => new Input(code: $code);
// THEN
expect($action)->toThrow(\InvalidArgumentException::class);
});
});

View file

@ -0,0 +1,429 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\UseCases\PrintableCoupon\Create;
use Application\UseCases\PrintableCoupon\Create\Input;
use Application\UseCases\PrintableCoupon\Create\Output;
use Application\UseCases\PrintableCoupon\Create\CreatePrintableCoupon;
use DateTime;
use Domain\Models\Entities\Campaign;
use Domain\Models\Entities\Coupon;
use Domain\Models\Entities\PrintableCoupon;
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\CampaignName;
use Domain\Models\ValueObjects\Coupon\CouponAvailabilityCalendar;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Coupon\CouponDiffusion;
use Domain\Models\ValueObjects\Coupon\CouponLegalInformation;
use Domain\Models\ValueObjects\Coupon\CouponLegalInformationArray;
use Domain\Models\ValueObjects\Coupon\CouponMovement;
use Domain\Models\ValueObjects\Coupon\CouponMovementArray;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\Coupon\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\CrmHashcode;
use Domain\Models\ValueObjects\IncoCode;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\Period;
use Domain\Models\ValueObjects\PicturePath;
use Domain\Models\ValueObjects\PrintableCoupon\MarketingDirectCode;
use Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponCode;
use Domain\Models\ValueObjects\PrintableCoupon\PrintedCoupon;
use Domain\Models\ValueObjects\PrintableCoupon\PrintedCouponId;
use Domain\Models\ValueObjects\PrintRules;
use Domain\Models\ValueObjects\ProcessingCode;
use Domain\Models\ValueObjects\ProcessingCodeArray;
use Domain\Ports\Repositories\CampaignRepository;
use Domain\Ports\Repositories\CouponRepository;
use Domain\Ports\Repositories\PrintableCouponRepository;
use Ramsey\Uuid\Uuid;
use Shared\Enums\Country;
use Shared\Enums\Currency;
use Shared\Helpers\DatesHelper;
use Shared\ValueObjects\Money;
covers(CreatePrintableCoupon::class, Input::class, Output::class);
describe('create coupon use case', function (): void {
$crmCustomerHashcode = new CrmHashcode(Uuid::uuid4());
$brandCode = new BrandCode('AAA01');
$campaignCode = new CampaignCode('CMP01');
$couponCode = new CouponCode('COU01');
$couponName = new CouponName('Coupon name');
$couponDiffusionStart = new DateTime()->modify('-1 month');
$couponDiffusionEnd = new DateTime()->modify('+1 month');
$makeCoupon = fn (array $overrides = []): Coupon => Coupon::create(
code: $couponCode,
name: $couponName,
amount: new MoneyAmount(new Money(Currency::EUR, 100)),
picture: new CouponPicture(
new PicturePath('files/product.jpg'),
new PicturePath('files/thumbnail.jpg'),
new PicturePath('files/mobile_thumbnail.jpg'),
),
diffusion: new CouponDiffusion(
new Period(
$overrides['diffusionStart'] ?? $couponDiffusionStart,
$overrides['diffusionEnd'] ?? $couponDiffusionEnd,
),
),
validityInformations: new CouponValidityInformationArray([
new CouponValidityInformation(
'Lorem ipsum dolor sit amet consectetur adipiscing elit.',
Country::FRANCE,
),
]),
legalInformations: new CouponLegalInformationArray([
new CouponLegalInformation(
'Lorem ipsum dolor sit amet consectetur adipiscing elit.',
Country::FRANCE,
),
]),
processingCode: new ProcessingCodeArray([
new ProcessingCode('5425003944130', Country::FRANCE),
]),
brandCode: $brandCode,
createdBy: ActorId::generate(),
products: new CouponProductArray([
new IncoCode(35784) ,
new IncoCode(15522),
]),
movements: new CouponMovementArray([
new CouponMovement(1000)
]),
availabilityCalendar: $overrides['availabilityCalendar'] ?? null,
);
$printRules = fn (?int $maxPerCustomer = null, ?int $cooldownDays = null): ?PrintRules => ($maxPerCustomer || $cooldownDays) ? new PrintRules(
maxPerCustomer: $maxPerCustomer,
cooldownDays: $cooldownDays,
) : null;
$campaignCoupon = fn (?int $maxPerCustomer = null, ?int $cooldownDays = null): CampaignCoupon => new CampaignCoupon(
$couponCode,
$printRules($maxPerCustomer, $cooldownDays),
);
$makeCampaign = fn (?int $maxPerCustomer = null, ?int $cooldownDays = null, bool $published = true): Campaign => Campaign::create(
code: $campaignCode,
name: new CampaignName('Campaign name'),
publication: $published,
printAllAtOnce: true,
coupons: new CampaignCouponArray([
$campaignCoupon($maxPerCustomer, $cooldownDays),
]),
brandCode: $brandCode,
actorId: ActorId::generate(),
);
$printedCoupon = new PrintedCoupon(
id: PrintedCouponId::generate(),
marketingCode: new MarketingDirectCode('0000000000001'),
printableCode: PrintableCouponCode::generate(),
couponCode: $couponCode,
campaignCode: $campaignCode,
customer: $crmCustomerHashcode,
printedAt: DatesHelper::now(),
);
$makeInput = fn (): Input => new Input(
campaignCode: $campaignCode,
couponCode: $couponCode,
crmCustomerHashcode: $crmCustomerHashcode,
);
it('creates a printable coupon', function () use ($makeInput, $crmCustomerHashcode, $makeCampaign, $makeCoupon, $campaignCoupon): void {
// GIVEN
$input = $makeInput();
$campaign = $makeCampaign();
$coupon = $makeCoupon();
$campaignRepository = \Mockery::mock(CampaignRepository::class);
$campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign);
$campaignRepository->shouldReceive('findCampaignCoupon')->once()->with($campaign->code, $coupon->code)
->andReturn($campaignCoupon());
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon);
$printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class);
$printableCouponRepository->shouldReceive('save')->once()->with(\Mockery::type(PrintableCoupon::class));
$useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository);
// WHEN
$result = $useCase->execute($input);
// THEN
expect($result)->toBeInstanceOf(Output::class)
->and($result->campaignCode)->toEqual($campaign->code)
->and($result->couponCode)->toEqual($coupon->code)
->and($result->crmCustomerHashcode)->toEqual($crmCustomerHashcode)
->and($result->distributableUntil)->toEqual($coupon->diffusion->period->end);
});
it('rejects a printable coupon when campaign not found', function () use ($makeInput, $makeCampaign): void {
// GIVEN
$input = $makeInput();
$campaign = $makeCampaign();
$campaignRepository = \Mockery::mock(CampaignRepository::class);
$campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn(null);
$couponRepository = \Mockery::mock(CouponRepository::class);
$printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class);
$useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository);
// WHEN
$action = fn (): Output => $useCase->execute($input);
// THEN
expect($action)->toThrow(\DomainException::class, sprintf('Campaign from code "%s" not found.', $input->campaignCode->value));
});
it('rejects a printable coupon when campaign is not published', function () use ($makeInput, $makeCampaign): void {
// GIVEN
$input = $makeInput();
$campaign = $makeCampaign(published: false);
$campaignRepository = \Mockery::mock(CampaignRepository::class);
$campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign);
$couponRepository = \Mockery::mock(CouponRepository::class);
$printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class);
$useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository);
// WHEN
$action = fn (): Output => $useCase->execute($input);
// THEN
expect($action)->toThrow(\DomainException::class, 'Campaign must be published.');
});
it('rejects a printable coupon when coupon is not found', function () use ($makeInput, $makeCampaign, $makeCoupon): void {
// GIVEN
$input = $makeInput();
$campaign = $makeCampaign();
$coupon = $makeCoupon();
$campaignRepository = \Mockery::mock(CampaignRepository::class);
$campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign);
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn(null);
$printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class);
$useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository);
// WHEN
$action = fn (): Output => $useCase->execute($input);
// THEN
expect($action)->toThrow(\DomainException::class, sprintf('Coupon code "%s" not found.', $input->couponCode->value));
});
it('rejects a printable coupon when campaign coupon is not found', function () use ($makeInput, $makeCampaign, $makeCoupon): void {
// GIVEN
$input = $makeInput();
$campaign = $makeCampaign();
$coupon = $makeCoupon();
$campaignRepository = \Mockery::mock(CampaignRepository::class);
$campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign);
$campaignRepository->shouldReceive('findCampaignCoupon')->once()->with($campaign->code, $coupon->code)
->andReturn(null);
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon);
$printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class);
$useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository);
// WHEN
$action = fn (): Output => $useCase->execute($input);
// THEN
expect($action)->toThrow(\DomainException::class, 'Coupon must be associated to the associated campaign coupon.');
});
it('rejects a printable coupon when outside of diffusion start', function () use ($makeInput, $makeCampaign, $makeCoupon, $campaignCoupon): void {
// GIVEN
$input = $makeInput();
$campaign = $makeCampaign();
$coupon = $makeCoupon(['diffusionStart' => new DateTime()->modify('+1 day')]);
$campaignRepository = \Mockery::mock(CampaignRepository::class);
$campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign);
$campaignRepository->shouldReceive('findCampaignCoupon')->once()->with($campaign->code, $coupon->code)
->andReturn($campaignCoupon());
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon);
$printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class);
$useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository);
// WHEN
$action = fn (): Output => $useCase->execute($input);
// THEN
expect($action)->toThrow(\DomainException::class, 'The printing period has not yet begun.');
});
it('rejects a printable coupon when outside of diffusion end', function () use ($makeInput, $makeCampaign, $makeCoupon, $campaignCoupon): void {
// GIVEN
$input = $makeInput();
$campaign = $makeCampaign();
$coupon = $makeCoupon(['diffusionEnd' => new DateTime()->modify('-1 day')]);
$campaignRepository = \Mockery::mock(CampaignRepository::class);
$campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign);
$campaignRepository->shouldReceive('findCampaignCoupon')->once()->with($campaign->code, $coupon->code)
->andReturn($campaignCoupon());
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon);
$printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class);
$useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository);
// WHEN
$action = fn (): Output => $useCase->execute($input);
// THEN
expect($action)->toThrow(\DomainException::class, 'The printing period has expired.');
});
it('creates a printable coupon when calendar period is inside diffusion period', function () use (
$makeInput,
$crmCustomerHashcode,
$makeCampaign,
$makeCoupon,
$campaignCoupon,
): void {
// GIVEN
$calendarStart = new DateTime()->modify('-1 day');
$calendarEnd = new DateTime()->modify('+1 day');
$input = $makeInput();
$campaign = $makeCampaign();
$coupon = $makeCoupon([
'availabilityCalendar' => new CouponAvailabilityCalendar([
new Period(
start: $calendarStart,
end: $calendarEnd,
),
]),
]);
$campaignRepository = \Mockery::mock(CampaignRepository::class);
$campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign);
$campaignRepository->shouldReceive('findCampaignCoupon')->once()->with($campaign->code, $coupon->code)
->andReturn($campaignCoupon());
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon);
$printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class);
$printableCouponRepository->shouldReceive('save')->once()->with(\Mockery::type(PrintableCoupon::class));
$useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository);
// WHEN
$result = $useCase->execute($input);
// THEN
expect($result)->toBeInstanceOf(Output::class)
->and($result->campaignCode)->toEqual($campaign->code)
->and($result->crmCustomerHashcode)->toEqual($crmCustomerHashcode)
->and($result->distributableUntil)->toEqual($calendarEnd);
});
it('rejects a printable coupon when maximum number of prints reached', function () use (
$makeInput,
$crmCustomerHashcode,
$makeCampaign,
$makeCoupon,
$campaignCoupon,
): void {
// GIVEN
$input = $makeInput();
$campaign = $makeCampaign(maxPerCustomer: 1);
$coupon = $makeCoupon();
$campaignRepository = \Mockery::mock(CampaignRepository::class);
$campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign);
$campaignRepository->shouldReceive('findCampaignCoupon')->once()->with($campaign->code, $coupon->code)
->andReturn($campaignCoupon(maxPerCustomer: 1));
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon);
$printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class);
$printableCouponRepository->shouldReceive('countPrintedCouponForCustomer')->once()->with(
$campaign->code,
$coupon->code,
$crmCustomerHashcode,
)->andReturn(5);
$useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository);
// WHEN
$action = fn (): Output => $useCase->execute($input);
// THEN
expect($action)->toThrow(\DomainException::class, 'Maximum number of prints reached.');
});
it('rejects a printable coupon when reprint period has expired', function () use (
$makeInput,
$crmCustomerHashcode,
$makeCampaign,
$makeCoupon,
$campaignCoupon,
$printedCoupon,
): void {
// GIVEN
$input = $makeInput();
$campaign = $makeCampaign(cooldownDays: 30);
$coupon = $makeCoupon();
$campaignRepository = \Mockery::mock(CampaignRepository::class);
$campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign);
$campaignRepository->shouldReceive('findCampaignCoupon')->once()->with($campaign->code, $coupon->code)
->andReturn($campaignCoupon(cooldownDays: 30));
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon);
$printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class);
$printableCouponRepository->shouldReceive('countPrintedCouponForCustomer')->once()->with(
$campaign->code,
$coupon->code,
$crmCustomerHashcode,
)->andReturn(5);
$printableCouponRepository->shouldReceive('findLastPrintedCouponForCustomer')->once()->with(
$campaign->code,
$coupon->code,
$crmCustomerHashcode,
)->andReturn($printedCoupon);
$useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository);
// WHEN
$action = fn (): Output => $useCase->execute($input);
// THEN
expect($action)->toThrow(\DomainException::class, 'Reprint period has not expired.');
});
});

View file

@ -0,0 +1,277 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\UseCases\PrintableCoupon\List;
use Application\UseCases\PrintableCoupon\List\Input;
use Application\UseCases\PrintableCoupon\List\ListPrintableCoupons;
use Application\UseCases\PrintableCoupon\List\Output;
use Application\UseCases\PrintableCoupon\List\PrintableCouponListItem;
use DateTime;
use Domain\Models\Entities\Coupon;
use Domain\Models\Entities\PrintableCoupon;
use Domain\Models\ValueObjects\Actor\ActorId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Coupon\CouponDiffusion;
use Domain\Models\ValueObjects\Coupon\CouponLegalInformation;
use Domain\Models\ValueObjects\Coupon\CouponLegalInformationArray;
use Domain\Models\ValueObjects\Coupon\CouponMovement;
use Domain\Models\ValueObjects\Coupon\CouponMovementArray;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\Coupon\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\CrmHashcode;
use Domain\Models\ValueObjects\IncoCode;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\Period;
use Domain\Models\ValueObjects\PicturePath;
use Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponCode;
use Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponSortCriteria;
use Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponSortField;
use Domain\Models\ValueObjects\ProcessingCode;
use Domain\Models\ValueObjects\ProcessingCodeArray;
use Domain\Ports\Repositories\CouponRepository;
use Domain\Ports\Repositories\PrintableCouponRepository;
use Ramsey\Uuid\Uuid;
use Shared\Enums\Country;
use Shared\Enums\Currency;
use Shared\ValueObjects\Money;
use Shared\ValueObjects\Pagination\Paginated;
use Shared\ValueObjects\Pagination\Pagination;
covers(ListPrintableCoupons::class, Input::class, Output::class, PrintableCouponListItem::class);
describe('list printable coupons use case', function (): void {
$createPicture = fn (string $name): CouponPicture => new CouponPicture(
new PicturePath("files/{$name}.jpg"),
new PicturePath("files/{$name}-thumbnail.jpg"),
new PicturePath("files/{$name}-mobile.jpg"),
);
$createInput = fn (PrintableCouponSortCriteria | null $sortCriteria = null): Input => new Input(
pagination: Pagination::firstPage(),
campaignCode: new CampaignCode('AAA01'),
crmCustomerHashcode: new CrmHashcode(Uuid::uuid4()),
sortCriteria: $sortCriteria ?? new PrintableCouponSortCriteria(
field: PrintableCouponSortField::CreatedAt,
direction: \SortDirection::Descending,
),
);
$createPrintableCoupon = fn (CouponCode $couponCode): PrintableCoupon => PrintableCoupon::hydrate(
code: PrintableCouponCode::generate(),
campaignCode: new CampaignCode('AAA01'),
couponCode: $couponCode,
couponName: new CouponName('Coupon name'),
crmCustomerHashcode: new CrmHashcode(Uuid::uuid4()),
distributableUntil: new DateTime('2027-01-01'),
);
$createCoupon = fn (CouponCode $code, CouponPicture $picture): Coupon => Coupon::create(
code: $code,
name: new CouponName('Coupon name'),
amount: new MoneyAmount(new Money(Currency::EUR, 100)),
picture: $picture,
diffusion: new CouponDiffusion(
new Period(
new DateTime('2026-01-01'),
new DateTime('2026-02-01'),
),
),
validityInformations: new CouponValidityInformationArray([
new CouponValidityInformation(
'Lorem ipsum dolor sit amet consectetur adipiscing elit.',
Country::FRANCE
),
]),
legalInformations: new CouponLegalInformationArray([
new CouponLegalInformation(
'Lorem ipsum dolor sit amet consectetur adipiscing elit.',
Country::FRANCE
),
]),
processingCode: new ProcessingCodeArray([
new ProcessingCode('5425003944130', Country::FRANCE),
]),
brandCode: new BrandCode('AAA01'),
createdBy: ActorId::generate(),
products: new CouponProductArray([
new IncoCode(35784),
new IncoCode(15522),
]),
movements: new CouponMovementArray([
new CouponMovement(1000)
]),
);
it('returns the printable coupons with the picture of their coupon', function () use (
$createInput,
$createPicture,
$createCoupon,
$createPrintableCoupon,
): void {
// GIVEN
$input = $createInput();
$picture = $createPicture('picture');
$couponCode = new CouponCode('AAA01');
$printable = $createPrintableCoupon($couponCode);
$coupon = $createCoupon($couponCode, $picture);
$paginated = new Paginated(pagination: $input->pagination, items: [$printable], total: 1);
$repository = \Mockery::mock(PrintableCouponRepository::class);
$repository->shouldReceive('findAllAvailable')
->once()
->with($input->pagination, $input->campaignCode, $input->crmCustomerHashcode, $input->sortCriteria)
->andReturn($paginated);
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')
->once()
->with($printable->couponCode)
->andReturn($coupon);
$useCase = new ListPrintableCoupons($repository, $couponRepository);
// WHEN
$output = $useCase->execute($input);
// THEN
$items = $output->printableCoupons->getItems();
expect($items)->toHaveCount(1)
->and($output->printableCoupons->getTotal())->toBe(1)
->and($items[0]->code)->toBe($printable->code)
->and($items[0]->couponCode)->toBe($printable->couponCode)
->and($items[0]->couponName)->toBe($printable->couponName)
->and($items[0]->couponPicture)->toBe($picture)
->and($items[0]->distributableUntil)->toBe($printable->distributableUntil);
});
it('forwards the sort criteria and keeps the order returned by the repository', function () use (
$createInput,
$createPicture,
$createCoupon,
$createPrintableCoupon,
): void {
// GIVEN
$sortCriteria = new PrintableCouponSortCriteria(
field: PrintableCouponSortField::Name,
direction: \SortDirection::Descending,
);
$input = $createInput($sortCriteria);
$code1 = new CouponCode('AAA01');
$code2 = new CouponCode('BBB02');
$picture1 = $createPicture('picture-1');
$picture2 = $createPicture('picture-2');
$printableCoupon1 = $createPrintableCoupon($code1);
$printableCoupon2 = $createPrintableCoupon($code2);
$repository = \Mockery::mock(PrintableCouponRepository::class);
$repository->shouldReceive('findAllAvailable')
->once()
->with($input->pagination, $input->campaignCode, $input->crmCustomerHashcode, $sortCriteria)
->andReturn(new Paginated(pagination: $input->pagination, items: [$printableCoupon2, $printableCoupon1], total: 2));
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')->with($code2)->once()
->andReturn($createCoupon($code2, $picture2));
$couponRepository->shouldReceive('findByCode')->with($code1)->once()
->andReturn($createCoupon($code1, $picture1));
$useCase = new ListPrintableCoupons($repository, $couponRepository);
// WHEN
$items = $useCase->execute($input)->printableCoupons->getItems();
// THEN
expect($items)->toHaveCount(2)
->and($items[0]->code)->toBe($printableCoupon2->code)
->and($items[0]->couponName)->toBe($printableCoupon2->couponName)
->and($items[0]->couponPicture)->toBe($picture2)
->and($items[1]->code)->toBe($printableCoupon1->code)
->and($items[1]->couponName)->toBe($printableCoupon1->couponName)
->and($items[1]->couponPicture)->toBe($picture1);
});
it('returns an empty list without error when there is no printable coupon', function () use ($createInput): void {
// GIVEN
$input = $createInput();
$paginated = new Paginated(pagination: $input->pagination, items: [], total: 0);
$repository = \Mockery::mock(PrintableCouponRepository::class);
$repository->shouldReceive('findAllAvailable')->once()->andReturn($paginated);
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldNotReceive('findByCode');
$useCase = new ListPrintableCoupons($repository, $couponRepository);
// WHEN
$output = $useCase->execute($input);
// THEN
expect($output->printableCoupons->getItems())->toBe([])
->and($output->printableCoupons->getTotal())->toBe(0);
});
it('loads a shared coupon only once', function () use (
$createInput,
$createPicture,
$createPrintableCoupon,
$createCoupon,
): void {
// GIVEN
$input = $createInput();
$picture = $createPicture('picture');
$couponCode = new CouponCode('AAA01');
$items = [$createPrintableCoupon($couponCode), $createPrintableCoupon($couponCode)];
$repository = \Mockery::mock(PrintableCouponRepository::class);
$repository->shouldReceive('findAllAvailable')
->once()
->andReturn(new Paginated(pagination: $input->pagination, items: $items, total: 2));
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')
->once()
->andReturn($createCoupon($couponCode, $picture));
$useCase = new ListPrintableCoupons($repository, $couponRepository);
// WHEN
$output = $useCase->execute($input);
// THEN
expect($output->printableCoupons->getItems())->toHaveCount(2);
});
it('throws when the coupon of a printable coupon does not exist', function () use ($createInput, $createPrintableCoupon): void {
// GIVEN
$input = $createInput();
$printable = $createPrintableCoupon(new CouponCode('BBB02'));
$repository = \Mockery::mock(PrintableCouponRepository::class);
$repository->shouldReceive('findAllAvailable')
->once()
->andReturn(new Paginated(pagination: $input->pagination, items: [$printable], total: 1));
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')->once()->andReturn(null);
$useCase = new ListPrintableCoupons($repository, $couponRepository);
// WHEN
$action = fn (): Output => $useCase->execute($input);
// THEN
expect($action)->toThrow(\LogicException::class);
});
});

Some files were not shown because too many files have changed in this diff Show more