Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 13 additions & 12 deletions .ai/context/project-status.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
## Current Sprint
---
- **Status:** Ongoing
- **Sprint:** Sprint 02
- **Status:** Done
- **Sprint:** Sprint 03
---

Steps

- [x] Docker
- [x] Redis
- [x] MySQL
- [x] HyperF Bootstrap
- [x] Health Check
- [x] Domain
- [x] Repository
- [x] Use Cases
- [x] Infrastructure
- [x] HTTP Endpoint
- [x] Tests

Future

- SQS
- Mongo
- AWS
- Worker
- Events
- Processamento assíncrono

Known Decisions

- ADR-001
- ADR-002
- ADR-002
- ADR-003
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,10 @@ Always create tests when creating new business rules.

Always explain architectural decisions.

Always follow PSR-12.
Always follow PSR-12.

For Sprint 3, implement the Payment domain first and only then move outward through the application, interface, and infrastructure layers.

The expected flow is: HTTP -> Interface -> Application -> Domain -> Repository Interface -> Infrastructure -> MySQL.

Do not start the sprint with controllers, database access, or model classes without first defining the domain behavior.
99 changes: 99 additions & 0 deletions app/Application/Payment/CreatePayment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

namespace App\Application\Payment;

use App\Domain\Payment\Payment;
use App\Domain\Payment\PaymentRepositoryInterface;
use App\Domain\Payment\PaymentStatus;
use App\Domain\Shared\IdGeneratorInterface;
use InvalidArgumentException;

/**
* Use case for creating a new payment.
*
* This use case orchestrates the creation of a payment entity following the business rules:
* 1. Generate a unique payment ID using the ID generator port
* 2. Validate the input (delegated to Payment entity)
* 3. Create the Payment aggregate with PENDING status
* 4. Persist the payment through the repository port
* 5. Return the output DTO
*
* Responsibilities:
* - Orchestrate ports and domain logic
* - Validate business rules at application layer
* - Transform DTOs to domain entities and back
*
* Does NOT handle:
* - Direct database access (delegated to repository)
* - HTTP concerns (delegated to controller)
* - Payment processing logic (part of other use cases)
*/
final class CreatePayment
{
public function __construct(
private readonly IdGeneratorInterface $idGenerator,
private readonly PaymentRepositoryInterface $paymentRepository,
) {}

/**
* Execute the use case of creating a new payment.
*
* @param CreatePaymentInput $input The input data for payment creation
*
* @return CreatePaymentOutput The created payment data
*
* @throws InvalidArgumentException If validation fails
*/
public function execute(CreatePaymentInput $input): CreatePaymentOutput
{
// Validate input status
$this->validateStatus($input->status);

// Generate unique payment ID with prefix
$paymentId = $this->idGenerator->generate('pay');

// Convert status string to enum
$status = PaymentStatus::tryFrom($input->status);
if ($status === null) {
throw new InvalidArgumentException(sprintf(
'Invalid payment status: %s',
$input->status,
));
}

// Create the Payment aggregate (domain entity with business rules)
// The Payment constructor will validate: amount, currency, description
$payment = new Payment(
id: $paymentId,
amount: $input->amount,
currency: $input->currency,
description: $input->description,
status: $status,
);

// Persist the payment through the repository port
$this->paymentRepository->save($payment);

// Return the output DTO
return CreatePaymentOutput::fromPayment($payment);
}

/**
* Validate that the provided status is valid.
*
* @param string $status The status to validate
*
* @throws InvalidArgumentException If the status is invalid
*/
private function validateStatus(string $status): void
{
if (! PaymentStatus::isValid($status)) {
throw new InvalidArgumentException(sprintf(
'Invalid payment status: %s. Valid statuses are: pending, paid, failed, canceled.',
$status,
));
}
}
}
15 changes: 15 additions & 0 deletions app/Application/Payment/CreatePaymentInput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace App\Application\Payment;

final class CreatePaymentInput
{
public function __construct(
public readonly int $amount,
public readonly string $currency,
public readonly string $description,
public readonly string $status = 'pending',
) {}
}
29 changes: 29 additions & 0 deletions app/Application/Payment/CreatePaymentOutput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace App\Application\Payment;

use App\Domain\Payment\Payment;

final class CreatePaymentOutput
{
public function __construct(
public readonly string $id,
public readonly int $amount,
public readonly string $currency,
public readonly string $description,
public readonly string $status,
) {}

public static function fromPayment(Payment $payment): self
{
return new self(
id: $payment->id(),
amount: $payment->amount(),
currency: $payment->currency(),
description: $payment->description(),
status: $payment->status()->value,
);
}
}
127 changes: 127 additions & 0 deletions app/Domain/Payment/Payment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php

declare(strict_types=1);

namespace App\Domain\Payment;

use InvalidArgumentException;

final class Payment
{
public const MIN_AMOUNT = 1;
public const MAX_AMOUNT = 100000000;

private const VALID_CURRENCIES = [
'BRL',
'USD',
'EUR',
'GBP',
];

/**
* @param string $id
* @param int $amount
* @param string $currency
* @param string $description
* @param PaymentStatus $status
*/
public function __construct(
private readonly string $id,
private int $amount,
private string $currency,
private string $description,
private PaymentStatus $status,
) {
$this->validateAmount();
$this->validateCurrency();
$this->validateDescription();
}

public function id(): string
{
return $this->id;
}

public function amount(): int
{
return $this->amount;
}

public function currency(): string
{
return $this->currency;
}

public function description(): string
{
return $this->description;
}

public function status(): PaymentStatus
{
return $this->status;
}

public function markAsPaid(): void
{
$this->assertTransition(PaymentStatus::PAID);
$this->status = PaymentStatus::PAID;
}

public function markAsFailed(): void
{
$this->assertTransition(PaymentStatus::FAILED);
$this->status = PaymentStatus::FAILED;
}

public function cancel(): void
{
$this->assertTransition(PaymentStatus::CANCELED);
$this->status = PaymentStatus::CANCELED;
}

private function validateAmount(): void
{
if ($this->amount < self::MIN_AMOUNT || $this->amount > self::MAX_AMOUNT) {
throw new InvalidArgumentException('The payment amount must be greater than zero and less than or equal to 100000000.');
}
}

private function validateCurrency(): void
{
if (! in_array(strtoupper($this->currency), self::VALID_CURRENCIES, true)) {
throw new InvalidArgumentException('The payment currency must be a valid ISO 4217 code.');
}

$this->currency = strtoupper($this->currency);
}

private function validateDescription(): void
{
$trimmed = trim($this->description);

if ($trimmed === '') {
throw new InvalidArgumentException('The payment description cannot be empty.');
}

$this->description = $trimmed;
}

private function assertTransition(PaymentStatus $nextStatus): void
{
$allowed = match ($this->status) {
PaymentStatus::PENDING => [PaymentStatus::PAID, PaymentStatus::FAILED, PaymentStatus::CANCELED],
PaymentStatus::PAID => [],
PaymentStatus::FAILED => [],
PaymentStatus::CANCELED => [],
};

if (! in_array($nextStatus, $allowed, true)) {
throw new InvalidArgumentException(sprintf(
'The status transition from %s to %s is not allowed.',
$this->status->value,
$nextStatus->value,
));
}
}
}
12 changes: 12 additions & 0 deletions app/Domain/Payment/PaymentRepositoryInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace App\Domain\Payment;

interface PaymentRepositoryInterface
{
public function save(Payment $payment): void;

public function findById(string $id): ?Payment;
}
18 changes: 18 additions & 0 deletions app/Domain/Payment/PaymentStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace App\Domain\Payment;

enum PaymentStatus: string
{
case PENDING = 'pending';
case PAID = 'paid';
case FAILED = 'failed';
case CANCELED = 'canceled';

public static function isValid(string $status): bool
{
return in_array($status, array_map(static fn(self $case): string => $case->value, self::cases()), true);
}
}
24 changes: 24 additions & 0 deletions app/Domain/Shared/IdGeneratorInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace App\Domain\Shared;

/**
* Port interface for generating unique identifiers.
*
* This interface defines the contract that any ID generation service
* must implement. Different adapters can provide various strategies
* (UUID, NanoID, custom prefixed IDs, etc.)
*/
interface IdGeneratorInterface
{
/**
* Generate a unique identifier with optional prefix.
*
* @param string $prefix Optional prefix to prepend to the generated ID
*
* @return string A unique identifier
*/
public function generate(string $prefix = ''): string;
}
Loading
Loading