diff --git a/.ai/context/project-status.md b/.ai/context/project-status.md index 716c102..43e2c83 100644 --- a/.ai/context/project-status.md +++ b/.ai/context/project-status.md @@ -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 \ No newline at end of file +- ADR-002 +- ADR-003 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 310692e..ab1eeb1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,4 +32,10 @@ Always create tests when creating new business rules. Always explain architectural decisions. -Always follow PSR-12. \ No newline at end of file +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. \ No newline at end of file diff --git a/app/Application/Payment/CreatePayment.php b/app/Application/Payment/CreatePayment.php new file mode 100644 index 0000000..3b0bca4 --- /dev/null +++ b/app/Application/Payment/CreatePayment.php @@ -0,0 +1,99 @@ +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, + )); + } + } +} diff --git a/app/Application/Payment/CreatePaymentInput.php b/app/Application/Payment/CreatePaymentInput.php new file mode 100644 index 0000000..42e8c5b --- /dev/null +++ b/app/Application/Payment/CreatePaymentInput.php @@ -0,0 +1,15 @@ +id(), + amount: $payment->amount(), + currency: $payment->currency(), + description: $payment->description(), + status: $payment->status()->value, + ); + } +} diff --git a/app/Domain/Payment/Payment.php b/app/Domain/Payment/Payment.php new file mode 100644 index 0000000..64fe385 --- /dev/null +++ b/app/Domain/Payment/Payment.php @@ -0,0 +1,127 @@ +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, + )); + } + } +} diff --git a/app/Domain/Payment/PaymentRepositoryInterface.php b/app/Domain/Payment/PaymentRepositoryInterface.php new file mode 100644 index 0000000..e28569b --- /dev/null +++ b/app/Domain/Payment/PaymentRepositoryInterface.php @@ -0,0 +1,12 @@ + $case->value, self::cases()), true); + } +} diff --git a/app/Domain/Shared/IdGeneratorInterface.php b/app/Domain/Shared/IdGeneratorInterface.php new file mode 100644 index 0000000..ecb6c4c --- /dev/null +++ b/app/Domain/Shared/IdGeneratorInterface.php @@ -0,0 +1,24 @@ + $payment->id()], + [ + 'amount' => $payment->amount(), + 'currency' => $payment->currency(), + 'description' => $payment->description(), + 'status' => $payment->status()->value, + ], + ); + + if (! $paymentModel->wasRecentlyCreated && ! $paymentModel->isDirty()) { + // Already exists and nothing changed, still valid + return; + } + + if (! $paymentModel->exists) { + throw new InvalidArgumentException('Failed to save payment to database'); + } + } + + /** + * Find a payment by its ID. + * + * @param string $id The payment ID + * + * @return Payment|null The payment entity or null if not found + */ + public function findById(string $id): ?Payment + { + $paymentModel = PaymentModel::find($id); + + if ($paymentModel === null) { + return null; + } + + return $this->mapModelToEntity($paymentModel); + } + + /** + * Map a database model to a domain entity. + * + * This private method encapsulates the mapping logic, + * ensuring the domain entity is created with the correct data types and state. + * + * @param PaymentModel $model The Eloquent model instance + * + * @return Payment The domain entity + * + * @throws InvalidArgumentException If the model data is invalid + */ + private function mapModelToEntity(PaymentModel $model): Payment + { + $status = PaymentStatus::tryFrom($model->status); + + if ($status === null) { + throw new InvalidArgumentException(sprintf( + 'Invalid payment status from database: %s', + $model->status, + )); + } + + return new Payment( + id: $model->id, + amount: $model->amount, + currency: $model->currency, + description: $model->description, + status: $status, + ); + } +} diff --git a/app/Infrastructure/Shared/UuidIdGenerator.php b/app/Infrastructure/Shared/UuidIdGenerator.php new file mode 100644 index 0000000..8def48b --- /dev/null +++ b/app/Infrastructure/Shared/UuidIdGenerator.php @@ -0,0 +1,35 @@ +toString(); + + if ($prefix === '') { + return $uuid; + } + + return sprintf('%s_%s', $prefix, $uuid); + } +} diff --git a/app/Interfaces/Http/Controllers/PaymentController.php b/app/Interfaces/Http/Controllers/PaymentController.php new file mode 100644 index 0000000..e1dbff2 --- /dev/null +++ b/app/Interfaces/Http/Controllers/PaymentController.php @@ -0,0 +1,113 @@ +input('amount') ?? 0), + currency: (string) ($request->input('currency') ?? ''), + description: (string) ($request->input('description') ?? ''), + status: (string) ($request->input('status') ?? 'pending'), + ); + + // Execute use case + $output = $this->createPaymentUseCase->execute($input); + + // Return success response with 201 Created + return $this->success($output, 201); + } catch (InvalidArgumentException $e) { + // Domain/Application validation errors + return $this->fail($e->getMessage(), 422); + } catch (\Throwable $e) { + // Unexpected errors + return $this->fail('Internal server error', 500); + } + } + + /** + * Helper method to return success responses. + * + * @param mixed $data The response data + * @param int $statusCode HTTP status code + * + * @return ResponseInterface Formatted response + */ + private function success(mixed $data, int $statusCode = 200): ResponseInterface + { + return $this->response->json([ + 'success' => true, + 'data' => $data, + ]) + ->withStatus($statusCode); + } + + /** + * Helper method to return error responses. + * + * @param string $message The error message + * @param int $statusCode HTTP status code + * + * @return ResponseInterface Formatted response + */ + private function fail(string $message, int $statusCode = 400): ResponseInterface + { + return $this->response->json([ + 'success' => false, + 'error' => $message, + ]) + ->withStatus($statusCode); + } +} diff --git a/app/Model/Payment.php b/app/Model/Payment.php new file mode 100644 index 0000000..d675b7d --- /dev/null +++ b/app/Model/Payment.php @@ -0,0 +1,63 @@ + + */ + protected array $fillable = [ + 'id', + 'amount', + 'currency', + 'description', + 'status', + ]; + + /** + * The attributes that should be cast to native types. + * @var array + */ + protected array $casts = [ + 'amount' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime' + ]; +} diff --git a/changelog.md b/changelog.md index 58840b7..25c5b17 100644 --- a/changelog.md +++ b/changelog.md @@ -6,6 +6,46 @@ This project follows the principles of Keep a Changelog. --- +## [0.3.0] - 2026-08-21 + +### Added + +- Sprint 3 planning for the Payment Domain and creation flow. +- `Payment` domain entity with business invariants and validation rules. +- `PaymentStatus` with explicit payment states and valid transitions. +- `CreatePayment` application use case. +- Input and output DTOs for payment creation. +- `PaymentRepositoryInterface` as the persistence port. +- `IdGeneratorInterface` and UUID-based ID generation. +- MySQL migration for the payments table. +- `PaymentRepository` infrastructure adapter for persistence. +- `Payment` persistence model and domain-to-infrastructure mapping. +- `POST /payments` endpoint for payment creation. +- Dependency injection configuration for the payment flow. +- Unit tests for the Payment domain. +- Application tests for the `CreatePayment` use case. +- Integration tests for the payment repository. +- HTTP tests for `POST /payments`. +- ADR-003 documenting the decision to prioritize the payment domain before external integrations. + +### Changed + +- README updated to reflect the completed Sprint 3 scope and the current project state. +- Roadmap updated to mark the Payment Domain, persistence and repository work as completed. +- Application architecture now demonstrates the expected flow from HTTP through Interface, Application, Domain, Repository Interface and Infrastructure. +- Payment persistence was implemented without moving business rules into controllers or infrastructure adapters. +- Composer dependencies and lockfile updated for the Sprint 3 implementation. + +### Validated + +- Payment domain rules are covered by automated tests. +- Payment creation use case is covered by automated tests. +- Repository persistence and retrieval are covered by integration tests. +- HTTP payment creation flow is covered by endpoint tests. +- Sprint 3 Definition of Done and acceptance criteria are satisfied. + +--- + ## [0.2.0] - 2026-08-12 ### Added @@ -41,4 +81,4 @@ This project follows the principles of Keep a Changelog. - ADR-001 documenting UUID as primary keys. - AI context documentation. - AGENTS.md for AI assistants. -- Sprint planning documentation. \ No newline at end of file +- Sprint planning documentation. diff --git a/composer.json b/composer.json index 785e84c..15bd86c 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,8 @@ "hyperf/memory": "~3.2.0", "hyperf/process": "~3.2.0", "hyperf/redis": "~3.2.0", - "hyperf/tracer": "~3.2.0" + "hyperf/tracer": "~3.2.0", + "ramsey/uuid": "^4.9" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.0", diff --git a/composer.lock b/composer.lock index d9844e1..abd490c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,67 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1efeaed828cfec2ef31a96ddfc301747", + "content-hash": "597f95292db0014e98c7272126b6cf46", "packages": [ + { + "name": "brick/math", + "version": "0.18.0", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.18.0" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2026-06-14T18:21:03+00:00" + }, { "name": "carbonphp/carbon-doctrine-types", "version": "3.2.0", @@ -4688,6 +4747,160 @@ }, "time": "2019-03-08T08:55:37+00:00" }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.3", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", + "shasum": "" + }, + "require": { + "brick/math": ">=0.8.16 <=0.18", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.3" + }, + "time": "2026-06-18T03:57:49+00:00" + }, { "name": "swow/psr7-plus", "version": "v1.1.2", diff --git a/config/autoload/dependencies.php b/config/autoload/dependencies.php index b02c0b6..bb3fd55 100644 --- a/config/autoload/dependencies.php +++ b/config/autoload/dependencies.php @@ -11,8 +11,20 @@ */ use App\Domain\Health\HealthServiceInterface; +use App\Domain\Payment\PaymentRepositoryInterface; +use App\Domain\Shared\IdGeneratorInterface; use App\Infrastructure\Health\HealthService; +use App\Infrastructure\Payment\PaymentRepository; +use App\Infrastructure\Shared\UuidIdGenerator; +/** + * Dependency Inversion + */ return [ + // Health HealthServiceInterface::class => HealthService::class, + + // Payment + PaymentRepositoryInterface::class => PaymentRepository::class, + IdGeneratorInterface::class => UuidIdGenerator::class, ]; diff --git a/config/routes.php b/config/routes.php index ff8386f..442092c 100644 --- a/config/routes.php +++ b/config/routes.php @@ -15,6 +15,9 @@ Router::addRoute(['GET', 'POST', 'HEAD'], '/', 'App\Controller\IndexController@index'); Router::get('/health', 'App\Interfaces\Http\Controllers\HealthController@index'); +// Payment routes +Router::post('/payments', 'App\Interfaces\Http\Controllers\PaymentController@create'); + Router::get('/favicon.ico', function () { return ''; }); diff --git a/docs/adr/ADR-003-payment-domain-first.md b/docs/adr/ADR-003-payment-domain-first.md new file mode 100644 index 0000000..a00fff7 --- /dev/null +++ b/docs/adr/ADR-003-payment-domain-first.md @@ -0,0 +1,107 @@ +# ADR-003 — Payment Domain First + +- **Status:** Accepted +- **Sprint:** Sprint 03 + +--- + +## Context + +A Sprint 2 consolidou a infraestrutura da aplicação e definiu uma base arquitetural inspirada em Clean Architecture e DDD. Agora, o próximo passo é iniciar o domínio financeiro do microsserviço. + +A tentação, neste momento, é começar pela rota HTTP, criar o controller e partir para o banco. Esse caminho seria rápido, mas iria ocultar a arquitetura que foi definida e misturaria regras de negócio com infraestrutura. + +Como o objetivo do projeto é demonstrar arquitetura profissional, é necessário que a primeira funcionalidade de pagamento reflita o fluxo correto de responsabilidade entre camadas. + +--- + +## Decision + +A Sprint 3 deve começar pela modelagem do domínio de `Payment`, seguido do caso de uso de criação e, somente depois, da implementação da infraestrutura e do endpoint HTTP. + +A implementação deve seguir a ordem de dependência abaixo: + +```text +HTTP + ↓ +Interface + ↓ +Application / Use Case + ↓ +Domain + ↓ +Repository Interface + ↓ +Infrastructure + ↓ +MySQL +``` + +Isso significa que: + +- a entidade `Payment` será definida primeiro; +- as regras de negócio e invariantes serão estabelecidas no domínio; +- o caso de uso `CreatePayment` orquestrará a criação do pagamento; +- o repositório será exposto por interface; +- a infraestrutura implementará a persistência real; +- o controller será apenas um adaptador de entrada/saída. + +--- + +## Alternatives Considered + +### 1. Iniciar pelo controller + +#### Vantagens + +- desenvolvimento inicial mais rápido; +- menos abstrações no começo; +- implementação funcional imediata. + +#### Desvantagens + +- mistura de regras de negócio com tecnologia; +- acoplamento com banco e HTTP; +- baixa testabilidade do domínio; +- dificulta a demonstração da arquitetura desejada. + +### 2. Iniciar pelo domínio (Escolhida) + +#### Vantagens + +- regras de negócio no lugar correto; +- maior clareza da modelagem de pagamentos; +- melhor coesão e menor acoplamento; +- facilita testes e evolução futura; +- mantém a arquitetura alinhada com Clean Architecture e DDD. + +#### Desvantagens + +- exige mais planejamento antes da implementação; +- demanda mais atenção à modelagem e invariantes do domínio; +- exige rigor ao definir contratos e responsabilidades. + +--- + +## Consequences + +### Positivas + +- a implementação real demonstra a arquitetura acordada; +- regras de negócio ficam isoladas e mais fáceis de testar; +- a aplicação se torna mais previsível e sustentável; +- o código reflete melhor o objetivo do microsserviço financeiro. + +### Negativas + +- a primeira entrega funcional leva mais tempo; +- exige maior rigor de modelagem antes da codificação; +- pode parecer mais "teórico" para quem busca uma implementação rápida. + +--- + +## Notes + +Esta decisão é importante porque o projeto não deve mostrar apenas uma estrutura de pastas bonitinha. O objetivo é demonstrar como uma aplicação de pagamentos de verdade organiza responsabilidades em camadas, separando domínio, aplicação, interface e infraestrutura. + +A Sprint 3 será a primeira oportunidade de validar esse princípio em funcionamento. diff --git a/docs/planning/roadmap.md b/docs/planning/roadmap.md index e7a6945..c2698d0 100644 --- a/docs/planning/roadmap.md +++ b/docs/planning/roadmap.md @@ -21,15 +21,20 @@ ## Sprint 3 -⬜ Payment Domain -⬜ Use Cases -⬜ Casos de uso do pagamento - -## Sprint 4 - -⬜ Persistence -⬜ Repository -⬜ Integração com banco de dados +✅ Payment Domain +✅ Use Cases +✅ Casos de uso do pagamento +✅ Definição do domínio de Payment +✅ Estados e transições do pagamento +✅ Primeiro caso de uso de criação +✅ Contratos de repositório e DTOs +✅ Infraestrutura inicial de persistência +✅ Endpoint `POST /payments` +✅ Testes do domínio e HTTP +✅ Atualização da documentação da sprint +✅ Persistence +✅ Repository +✅ Integração com banco de dados ## Sprint 5 diff --git a/docs/planning/sprint-003.md b/docs/planning/sprint-003.md new file mode 100644 index 0000000..29a3fd6 --- /dev/null +++ b/docs/planning/sprint-003.md @@ -0,0 +1,142 @@ +# Sprint 03 — Payment Domain + +## Objetivo da Sprint + +Definir o domínio de pagamentos e implementar o primeiro fluxo de criação de um pagamento, respeitando a arquitetura definida na Sprint 2. + +A prioridade desta Sprint é estabelecer as regras de negócio do `Payment` antes de qualquer implementação de infraestrutura ou endpoint HTTP. O objetivo não é apenas "criar um registro no banco", mas garantir que o domínio financeiro fique corretamente modelado, validado e testado. + +--- + +## Definition of Done + +Ao final da Sprint, o projeto deve ser capaz de: + +- modelar a entidade `Payment` com regras de domínio e invariantes; +- definir estados e transições válidas do pagamento; +- criar o caso de uso `CreatePayment` sem lógica de infraestrutura no domínio; +- implementar o contrato de persistência do repository; +- expor a criação de pagamento via `POST /payments`; +- validar o fluxo com testes de domínio, aplicação e HTTP; +- manter a arquitetura no padrão: HTTP → Interface → Application → Domain → Repository Interface → Infrastructure. + +--- + +## Backlog da Sprint + +### 1. Domínio de Payment + +- [x] Definir a entidade `Payment`; +- [x] definir campos e tipos; +- [x] validar valores mínimos e máximos; +- [x] mapear status possíveis; +- [x] definir transições permitidas; +- [x] separar regras do domínio das regras da aplicação. + +### 2. Contratos e portas + +- [x] criar `PaymentRepositoryInterface`; +- [x] definir DTOs de entrada e saída; +- [x] estabelecer interfaces para ports necessários ao caso de uso; +- [x] evitar acoplamento com banco e controllers. + +### 3. Caso de uso de criação + +- [x] implementar `CreatePayment`; +- [x] gerar `id` único; +- [x] validar `amount`, `currency`, `status`, `description`; +- [x] criar a entidade com timestamps corretos; +- [x] persistir apenas através do contrato do repositório. + +### 4. Infraestrutura + +- [x] criar migration MySQL para pagamentos; +- [x] implementar repository concreto; +- [x] configurar conexão e mapeamento do modelo; +- [x] manter a infra como adaptador, sem regras de negócio. + +### 5. Interface HTTP + +- [x] criar `POST /payments`; +- [x] definir request e response; +- [x] criar controller delegando para o caso de uso; +- [x] manter o controller fino e sem regra de negócio. + +### 6. Testes + +- [x] testes unitários do domínio; +- [x] testes do caso de uso; +- [x] testes de integração do repository; +- [x] testes do endpoint HTTP. + +### 7. Documentação + +- [x] atualizar roadmap; +- [x] registrar ADR quando houver decisão arquitetural relevante; +- [x] manter a documentação alinhada com a implementação real. + +--- + +## Riscos e dependências + +### Riscos + +- definir status e transições do pagamento de forma inconsistente; +- misturar validação de aplicação com validação de domínio; +- começar pelo controller e criar acoplamento com a infraestrutura; +- permitir regras de negócio dentro da camada HTTP; +- modelar `Money` e `Currency` sem necessidade real, gerando complexidade desnecessária. + +### Dependências + +- arquitetura base da Sprint 2 concluída; +- estrutura Clean Architecture já estabelecida; +- conhecimento do padrão Health já validado no projeto; +- banco MySQL disponível para integração; +- ambiente Docker funcionando para validação do endpoint. + +--- + +## Critérios de aceite + +A Sprint 3 será considerada concluída quando: + +1. `Payment` estiver modelado como entidade de domínio com invariantes; +2. `CreatePayment` estiver implementado como caso de uso da aplicação; +3. o repository estiver exposto por interface e implementado na infraestrutura; +4. a criação de pagamento funcionar via `POST /payments`; +5. todos os testes relevantes do domínio e HTTP estiverem verdes; +6. o fluxo da aplicação respeitar a camada arquitetural esperada; +7. a documentação do projeto refletir corretamente a nova entrega. + +--- + +## Regras de implementação + +- não criar lógica de negócio dentro do controller; +- não acessar banco diretamente na camada de aplicação; +- sempre criar interfaces antes das implementações concretas; +- sempre cobrir regras de negócio com testes; +- manter as decisões arquiteturais explícitas e documentadas. + +--- + +## Fluxo esperado da implementação + +```text +HTTP + ↓ +Interface + ↓ +Application / Use Case + ↓ +Domain + ↓ +Repository Interface + ↓ +Infrastructure + ↓ +MySQL +``` + +Esse fluxo deve ser visível no código e na organização dos arquivos, demonstrando que o projeto segue Clean Architecture e DDD de forma prática. diff --git a/migrations/2026_08_14_200430_create_payments_table.php b/migrations/2026_08_14_200430_create_payments_table.php new file mode 100644 index 0000000..2b19a19 --- /dev/null +++ b/migrations/2026_08_14_200430_create_payments_table.php @@ -0,0 +1,46 @@ +string('id', 64)->primary()->comment('Unique payment identifier with "pay_" prefix'); + + // Payment Data + $table->unsignedBigInteger('amount')->comment('Payment amount in cents (e.g., 2500 = 25.00)'); + $table->string('currency', 3)->comment('ISO 4217 currency code (BRL, USD, EUR, GBP)'); + $table->text('description')->comment('Payment description/reference'); + + // Status + $table->enum('status', ['pending', 'paid', 'failed', 'canceled']) + ->default('pending') + ->comment('Current payment status'); + + // Timestamps + $table->timestamp('created_at')->useCurrent()->comment('Payment creation timestamp'); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate()->comment('Last update timestamp'); + + // Indexes for performance + $table->index('status', 'idx_payments_status'); + $table->index('created_at', 'idx_payments_created_at'); + $table->index('currency', 'idx_payments_currency'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('payments'); + } +}; diff --git a/readme.md b/readme.md index 2b588fa..8ae9bba 100644 --- a/readme.md +++ b/readme.md @@ -1,10 +1,10 @@ # Dev Payment API -Microsserviço de processamento de pagamentos desenvolvido com **HyperF**, seguindo princípios de arquitetura limpa, DDD e boas práticas de engenharia para sistemas financeiros. +Microsserviço de processamento de pagamentos desenvolvido com **HyperF**, seguindo princípios de **Clean Architecture**, **DDD** e boas práticas de engenharia para sistemas financeiros. -O objetivo deste projeto é servir como um portfolio técnico de um microsserviço de produção, com foco em qualidade de código, organização de camadas, infraestrutura reproduzível e evolução gradual. +O objetivo deste projeto é servir como um portfólio técnico de um microsserviço de produção, com foco em qualidade de código, separação de responsabilidades, infraestrutura reproduzível, testes automatizados e evolução incremental. -> Status atual: Sprint 2 em andamento; bootstrap HyperF implementado e validação do health check em progresso. +> **Status atual: Sprint 3 concluída.** O domínio de `Payment`, o caso de uso de criação, persistência em MySQL e o endpoint `POST /payments` estão implementados e cobertos por testes automatizados. --- @@ -18,68 +18,84 @@ O objetivo deste projeto é servir como um portfolio técnico de um microsservi - Docker - Docker Compose - Make -- PHPUnit / Pest *(sprint futura)* +- PHPUnit / testes automatizados - AWS SQS *(sprint futura)* --- # Objetivo do projeto -Construir uma base sólida para um microsserviço de pagamentos, com: +Construir uma base sólida para um microsserviço de pagamentos, evoluindo de forma incremental: - infraestrutura profissional em Docker; - aplicação executando com HyperF; -- estrutura de arquitetura preparada para Clean Architecture e DDD; -- endpoints iniciais para observabilidade e saúde da aplicação; -- evolução controlada para regras de negócio e integrações. +- Clean Architecture e DDD como fundamentos; +- domínio financeiro modelado com regras explícitas; +- persistência desacoplada por contratos; +- testes automatizados nas principais camadas; +- evolução futura para mensageria, auditoria, observabilidade e AWS. --- -# Sprint atual +# Sprint 3 — Payment Domain -## Sprint 2 — Bootstrap HyperF +A Sprint 3 foi concluída com a implementação do primeiro fluxo funcional do domínio financeiro. -A Sprint 2 tem como foco inicializar a aplicação HyperF e garantir que o microsserviço execute corretamente em Docker, pronto para receber regras de negócio. +### Entregas concluídas -### Definition of Done +- Entidade `Payment` com regras e invariantes de domínio; +- `PaymentStatus` com estados e transições válidas; +- caso de uso `CreatePayment`; +- DTOs de entrada e saída; +- `PaymentRepositoryInterface`; +- geração de identificadores UUID através de contrato próprio; +- migration MySQL para pagamentos; +- `PaymentRepository` como adaptador de infraestrutura; +- modelo de persistência `Payment`; +- endpoint `POST /payments`; +- configuração de injeção de dependências; +- testes de domínio; +- testes do caso de uso; +- testes de integração do repository; +- testes HTTP do fluxo de criação; +- ADR-003 documentando a abordagem domain-first da sprint. -Ao final da sprint, o projeto deve permitir: +### Fluxo arquitetural validado -```bash -make setup -``` - -E expor: - -```http -GET /health +```text +HTTP + ↓ +Interface + ↓ +Application / Use Case + ↓ +Domain + ↓ +Repository Interface + ↓ +Infrastructure + ↓ +MySQL ``` -Com resposta esperada: +O fluxo foi implementado mantendo as regras de negócio no domínio e evitando acoplamento direto entre aplicação, controller e infraestrutura. -```json -{ - "status": "UP", - "service": "dev-payment-api", - "version": "0.2.0", - "environment": "dev", - "timestamp": "2026-08-12T00:00:00Z" -} -``` +### Validação -Isso confirma que: +A Sprint 3 atende aos critérios definidos no planejamento: -- HyperF está funcionando; -- Swoole está funcionando; -- roteamento está funcionando; -- Docker está funcionando; -- a aplicação está pronta para evoluir. +- domínio de `Payment` modelado com invariantes; +- `CreatePayment` implementado como caso de uso; +- repository exposto por interface e implementado na infraestrutura; +- criação de pagamento disponível através de `POST /payments`; +- regras de domínio, aplicação, persistência e HTTP cobertas por testes; +- documentação e roadmap atualizados de acordo com a implementação real. --- # Arquitetura -A arquitetura base do microsserviço seguirá uma estrutura inspirada em Clean Architecture e DDD: +A aplicação segue uma estrutura inspirada em **Clean Architecture** e **DDD**: ```text app/ @@ -92,7 +108,7 @@ app/ └── Config/ ``` -Essa organização foi definida antes do início do desenvolvimento de regras de negócio para evitar refatorações futuras. +A separação das camadas permite que as regras de negócio permaneçam independentes de HTTP, banco de dados e detalhes de infraestrutura. --- @@ -130,6 +146,8 @@ dev-payment-api │ ├── Interfaces/ │ ├── Shared/ │ └── Config/ +├── migrations/ +├── test/ ├── docker-compose.yml ├── Makefile ├── AGENTS.md @@ -151,48 +169,58 @@ cd dev-payment-api ## Opção rápida: setup completo -Se você quiser iniciar o ambiente de forma automatizada, o comando abaixo já sobe os containers, instala as dependências do Composer e inicia a aplicação HyperF: - ```bash make setup ``` ## Alternativa passo a passo -Se preferir rodar os comandos um por um: - ```bash make build make up make doctor ``` -### Se quiser rodar com hot reload (reiniciando automaticamente ao salvar arquivos), utilize: +### Iniciar a aplicação + +Com hot reload: ```bash make app-watch ``` -### Ou se quiser apenas iniciar a aplicação HyperF sem hot reload: + +Ou sem hot reload: ```bash make app-start ``` -### O que o comando `make doctor` faz? - -O `make doctor` é útil para validar rapidamente o estado do ambiente. Ele verifica: +## Executar os testes -- status dos containers Docker; -- versão do PHP; -- versão do Composer; -- versão da aplicação HyperF. +```bash +make app-test +``` ## Verificando a aplicação +Health check: + ```bash curl http://localhost:9501/health ``` +Criação de pagamento: + +```bash +curl -X POST http://localhost:9501/payments \ + -H "Content-Type: application/json" \ + -d '{ + "amount": 100.00, + "currency": "BRL", + "description": "Pagamento de teste" + }' +``` + ## Acessando o container ```bash @@ -238,23 +266,39 @@ Toda a documentação do projeto está em `docs/`. - ADRs → `docs/adr` - Planejamento → `docs/planning` - Arquitetura base → `docs/adr/ADR-002-base-architecture.md` +- Payment Domain → `docs/adr/ADR-003-payment-domain-first.md` - HyperF → `docs/hyperf` --- # Roadmap -- [x] Sprint 1: infraestrutura e ambiente base concluída -- [ ] Sprint 2: HyperF + bootstrap da aplicação + health check -- [ ] Sprint 3: domínio de pagamentos e casos de uso -- [ ] Sprint 4: persistência e repositories -- [ ] Sprint 5: mensageria e workers +- [x] Sprint 1: infraestrutura e ambiente base +- [x] Sprint 2: HyperF + bootstrap da aplicação + health check +- [x] Sprint 3: Payment Domain + CreatePayment + persistência + repository + `POST /payments` +- [ ] Sprint 5: mensageria e workers com SQS - [ ] Sprint 6: MongoDB e auditoria - [ ] Sprint 7: observabilidade e monitoramento - [ ] Sprint 8: deploy e infraestrutura AWS +> A Sprint 3 representa a primeira etapa funcional do domínio financeiro e estabelece a base para processamento assíncrono, auditoria, observabilidade e deploy nas próximas etapas. + +--- + +# Próximas etapas + +Após a conclusão da Sprint 3, o projeto pode evoluir para processamento assíncrono e integração orientada a eventos, mantendo o domínio desacoplado dos mecanismos de infraestrutura. + +As próximas entregas previstas são: + +1. SQS e workers; +2. publicação e consumo de eventos; +3. MongoDB para auditoria; +4. observabilidade com Prometheus e Grafana; +5. deploy na AWS. + --- # Licença -MIT \ No newline at end of file +MIT diff --git a/test/Cases/Application/Payment/CreatePaymentTest.php b/test/Cases/Application/Payment/CreatePaymentTest.php new file mode 100644 index 0000000..b59977d --- /dev/null +++ b/test/Cases/Application/Payment/CreatePaymentTest.php @@ -0,0 +1,371 @@ +idGeneratorMock = $this->createMock(IdGeneratorInterface::class); + $this->repositoryMock = $this->createMock(PaymentRepositoryInterface::class); + $this->useCase = new CreatePayment($this->idGeneratorMock, $this->repositoryMock); + } + + public function testItCreatesPaymentSuccessfullyWithValidInput(): void + { + // Arrange + $input = new CreatePaymentInput( + amount: 2500, + currency: 'BRL', + description: 'Pagamento de teste', + status: 'pending', + ); + + $this->idGeneratorMock + ->expects($this->once()) + ->method('generate') + ->with('pay') + ->willReturn('pay_550e8400e29b41d4a716446655440000'); + + $this->repositoryMock + ->expects($this->once()) + ->method('save') + ->with($this->isInstanceOf(Payment::class)); + + // Act + $output = $this->useCase->execute($input); + + // Assert + $this->assertSame('pay_550e8400e29b41d4a716446655440000', $output->id); + $this->assertSame(2500, $output->amount); + $this->assertSame('BRL', $output->currency); + $this->assertSame('Pagamento de teste', $output->description); + $this->assertSame('pending', $output->status); + } + + public function testItCreatesPaymentWithDifferentValidStatuses(): void + { + // Test that only 'pending' is valid for creation (other statuses would be for transitions) + $input = new CreatePaymentInput( + amount: 5000, + currency: 'USD', + description: 'Test payment', + status: 'pending', + ); + + $this->idGeneratorMock + ->expects($this->once()) + ->method('generate') + ->with('pay') + ->willReturn('pay_test_id'); + + $this->repositoryMock + ->expects($this->once()) + ->method('save') + ->with($this->isInstanceOf(Payment::class)); + + $output = $this->useCase->execute($input); + + $this->assertSame('pending', $output->status); + } + + public function testItRejectsInvalidStatus(): void + { + // Arrange + $input = new CreatePaymentInput( + amount: 2500, + currency: 'BRL', + description: 'Pagamento de teste', + status: 'invalid_status', + ); + + $this->idGeneratorMock->expects($this->never())->method('generate'); + $this->repositoryMock->expects($this->never())->method('save'); + + // Act & Assert + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid payment status: invalid_status. Valid statuses are: pending, paid, failed, canceled.'); + + $this->useCase->execute($input); + } + + public function testItRejectsInvalidAmount(): void + { + // Arrange + $input = new CreatePaymentInput( + amount: 0, + currency: 'BRL', + description: 'Pagamento de teste', + status: 'pending', + ); + + $this->idGeneratorMock + ->expects($this->once()) + ->method('generate') + ->with('pay') + ->willReturn('pay_test_id'); + + $this->repositoryMock->expects($this->never())->method('save'); + + // Act & Assert + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The payment amount must be greater than zero and less than or equal to 100000000.'); + + $this->useCase->execute($input); + } + + public function testItRejectsAmountExceedingMaximum(): void + { + // Arrange + $input = new CreatePaymentInput( + amount: 100000001, // MAX_AMOUNT is 100000000 + currency: 'BRL', + description: 'Pagamento de teste', + status: 'pending', + ); + + $this->idGeneratorMock + ->expects($this->once()) + ->method('generate') + ->with('pay') + ->willReturn('pay_test_id'); + + $this->repositoryMock->expects($this->never())->method('save'); + + // Act & Assert + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The payment amount must be greater than zero and less than or equal to 100000000.'); + + $this->useCase->execute($input); + } + + public function testItRejectsInvalidCurrency(): void + { + // Arrange + $input = new CreatePaymentInput( + amount: 2500, + currency: 'XYZ', + description: 'Pagamento de teste', + status: 'pending', + ); + + $this->idGeneratorMock + ->expects($this->once()) + ->method('generate') + ->with('pay') + ->willReturn('pay_test_id'); + + $this->repositoryMock->expects($this->never())->method('save'); + + // Act & Assert + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The payment currency must be a valid ISO 4217 code.'); + + $this->useCase->execute($input); + } + + public function testItRejectsEmptyDescription(): void + { + // Arrange + $input = new CreatePaymentInput( + amount: 2500, + currency: 'BRL', + description: ' ', + status: 'pending', + ); + + $this->idGeneratorMock + ->expects($this->once()) + ->method('generate') + ->with('pay') + ->willReturn('pay_test_id'); + + $this->repositoryMock->expects($this->never())->method('save'); + + // Act & Assert + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The payment description cannot be empty.'); + + $this->useCase->execute($input); + } + + public function testItNormalizeCurrencyToUppercase(): void + { + // Arrange + $input = new CreatePaymentInput( + amount: 2500, + currency: 'brl', + description: 'Test payment', + status: 'pending', + ); + + $this->idGeneratorMock + ->expects($this->once()) + ->method('generate') + ->with('pay') + ->willReturn('pay_test_id'); + + $this->repositoryMock + ->expects($this->once()) + ->method('save') + ->with($this->isInstanceOf(Payment::class)); + + // Act + $output = $this->useCase->execute($input); + + // Assert + $this->assertSame('BRL', $output->currency); + } + + public function testItTrimsDescriptionWhitespace(): void + { + // Arrange + $input = new CreatePaymentInput( + amount: 2500, + currency: 'BRL', + description: ' Pagamento com espaços ', + status: 'pending', + ); + + $this->idGeneratorMock + ->expects($this->once()) + ->method('generate') + ->with('pay') + ->willReturn('pay_test_id'); + + $this->repositoryMock + ->expects($this->once()) + ->method('save') + ->with($this->isInstanceOf(Payment::class)); + + // Act + $output = $this->useCase->execute($input); + + // Assert + $this->assertSame('Pagamento com espaços', $output->description); + } + + public function testItCallsRepositorySaveWithCorrectPayment(): void + { + // Arrange + $input = new CreatePaymentInput( + amount: 2500, + currency: 'BRL', + description: 'Pagamento de teste', + status: 'pending', + ); + + $generatedId = 'pay_550e8400e29b41d4a716446655440000'; + + $this->idGeneratorMock + ->expects($this->once()) + ->method('generate') + ->with('pay') + ->willReturn($generatedId); + + /** @var Payment|null $paymentCapture */ + $paymentCapture = null; + + $this->repositoryMock + ->expects($this->once()) + ->method('save') + ->willReturnCallback(function (Payment $payment) use (&$paymentCapture) { + $paymentCapture = $payment; + }); + + // Act + $this->useCase->execute($input); + + // Assert + $this->assertNotNull($paymentCapture); + $this->assertSame($generatedId, $paymentCapture->id()); + $this->assertSame(2500, $paymentCapture->amount()); + $this->assertSame('BRL', $paymentCapture->currency()); + $this->assertSame('Pagamento de teste', $paymentCapture->description()); + $this->assertSame(PaymentStatus::PENDING, $paymentCapture->status()); + } + + public function testItGeneratesIdWithCorrectPrefix(): void + { + // Arrange + $input = new CreatePaymentInput( + amount: 2500, + currency: 'BRL', + description: 'Pagamento de teste', + status: 'pending', + ); + + $this->idGeneratorMock + ->expects($this->once()) + ->method('generate') + ->with('pay') // Verify the prefix is correct + ->willReturn('pay_unique_id'); + + $this->repositoryMock + ->expects($this->once()) + ->method('save') + ->with($this->isInstanceOf(Payment::class)); + + // Act + $this->useCase->execute($input); + + // Assert - the expectation already verifies the prefix was passed correctly + } + + public function testItReturnsCorrectOutputDtoStructure(): void + { + // Arrange + $input = new CreatePaymentInput( + amount: 7500, + currency: 'EUR', + description: 'Transferência internacional', + status: 'pending', + ); + + $this->idGeneratorMock + ->expects($this->once()) + ->method('generate') + ->with('pay') + ->willReturn('pay_euro_123'); + + $this->repositoryMock + ->expects($this->once()) + ->method('save') + ->with($this->isInstanceOf(Payment::class)); + + // Act + $output = $this->useCase->execute($input); + + // Assert + $this->assertIsString($output->id); + $this->assertIsInt($output->amount); + $this->assertIsString($output->currency); + $this->assertIsString($output->description); + $this->assertIsString($output->status); + } +} diff --git a/test/Cases/Domain/PaymentTest.php b/test/Cases/Domain/PaymentTest.php new file mode 100644 index 0000000..aabbf01 --- /dev/null +++ b/test/Cases/Domain/PaymentTest.php @@ -0,0 +1,90 @@ +assertSame('pay_123', $payment->id()); + $this->assertSame(2500, $payment->amount()); + $this->assertSame('BRL', $payment->currency()); + $this->assertSame(PaymentStatus::PENDING, $payment->status()); + $this->assertSame('Pagamento de teste', $payment->description()); + } + + public function testItRejectsZeroOrNegativeAmount(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The payment amount must be greater than zero and less than or equal to 100000000.'); + + new Payment( + id: 'pay_123', + amount: 0, + currency: 'BRL', + description: 'Teste', + status: PaymentStatus::PENDING, + ); + } + + public function testItRejectsInvalidCurrency(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The payment currency must be a valid ISO 4217 code.'); + + new Payment( + id: 'pay_123', + amount: 2500, + currency: 'XYZ', + description: 'Teste', + status: PaymentStatus::PENDING, + ); + } + + public function testItAllowsValidStatusTransition(): void + { + $payment = new Payment( + id: 'pay_123', + amount: 2500, + currency: 'BRL', + description: 'Teste', + status: PaymentStatus::PENDING, + ); + + $payment->markAsPaid(); + + $this->assertSame(PaymentStatus::PAID, $payment->status()); + } + + public function testItRejectsInvalidTransition(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The status transition from paid to paid is not allowed.'); + + $payment = new Payment( + id: 'pay_123', + amount: 2500, + currency: 'BRL', + description: 'Teste', + status: PaymentStatus::PENDING, + ); + + $payment->markAsPaid(); + $payment->markAsPaid(); + } +} diff --git a/test/Cases/Infrastructure/Payment/PaymentRepositoryTest.php b/test/Cases/Infrastructure/Payment/PaymentRepositoryTest.php new file mode 100644 index 0000000..5ada40b --- /dev/null +++ b/test/Cases/Infrastructure/Payment/PaymentRepositoryTest.php @@ -0,0 +1,114 @@ +repository = new PaymentRepository(); + } + + protected function tearDown(): void + { + PaymentModel::query() + ->whereIn('id', [ + 'pay_repository_test_001', + 'pay_repository_test_002', + ]) + ->delete(); + + restore_error_handler(); + restore_exception_handler(); + + parent::tearDown(); + } + + public function testItSavesAndFindsPayment(): void + { + // Arrange + $payment = new Payment( + id: 'pay_repository_test_001', + amount: 2500, + currency: 'BRL', + description: 'Pagamento de integração', + status: PaymentStatus::PENDING, + ); + + // Act + $this->repository->save($payment); + + $foundPayment = $this->repository->findById($payment->id()); + + // Assert + $this->assertNotNull($foundPayment); + $this->assertSame($payment->id(), $foundPayment->id()); + $this->assertSame($payment->amount(), $foundPayment->amount()); + $this->assertSame($payment->currency(), $foundPayment->currency()); + $this->assertSame($payment->description(), $foundPayment->description()); + $this->assertSame($payment->status(), $foundPayment->status()); + } + + public function testItUpdatesExistingPayment(): void + { + // Arrange + $payment = new Payment( + id: 'pay_repository_test_002', + amount: 2500, + currency: 'BRL', + description: 'Pagamento de integração', + status: PaymentStatus::PENDING, + ); + + $this->repository->save($payment); + + $updatedPayment = new Payment( + id: 'pay_repository_test_002', + amount: 5000, + currency: 'BRL', + description: 'Pagamento atualizado', + status: PaymentStatus::PAID, + ); + + // Act + $this->repository->save($updatedPayment); + + $foundPayment = $this->repository->findById($updatedPayment->id()); + + // Assert + $this->assertNotNull($foundPayment); + $this->assertSame($updatedPayment->id(), $foundPayment->id()); + $this->assertSame($updatedPayment->amount(), $foundPayment->amount()); + $this->assertSame($updatedPayment->currency(), $foundPayment->currency()); + $this->assertSame($updatedPayment->description(), $foundPayment->description()); + $this->assertSame($updatedPayment->status(), $foundPayment->status()); + + $this->assertSame( + 1, + PaymentModel::query() + ->where('id', 'pay_repository_test_002') + ->count() + ); + } + + public function testItReturnsNullWhenPaymentDoesNotExist(): void + { + // Act + $payment = $this->repository->findById('pay_payment_that_does_not_exist'); + + // Assert + $this->assertNull($payment); + } +} diff --git a/test/Cases/PaymentControllerTest.php b/test/Cases/PaymentControllerTest.php new file mode 100644 index 0000000..6b3a75c --- /dev/null +++ b/test/Cases/PaymentControllerTest.php @@ -0,0 +1,186 @@ +whereIn('id', $this->createdPaymentIds) + ->delete(); + + restore_error_handler(); + restore_exception_handler(); + + parent::tearDown(); + } + + public function testItCreatesPaymentThroughHttpEndpoint(): void + { + // Arrange + $payload = [ + 'amount' => 2500, + 'currency' => 'BRL', + 'description' => 'Pagamento HTTP de integração', + 'status' => 'pending', + ]; + + // Act + $response = $this->post('/payments', $payload); + + // Assert + $response + ->assertCreated() + ->assertJsonStructure([ + 'success', + 'data' => [ + 'id', + 'amount', + 'currency', + 'description', + 'status', + ], + ]) + ->assertJsonFragment([ + 'success' => true, + 'amount' => 2500, + 'currency' => 'BRL', + 'description' => 'Pagamento HTTP de integração', + 'status' => 'pending', + ]); + + $responseData = $response->json(); + + if (isset($responseData['data']['id'])) { + $this->createdPaymentIds[] = $responseData['data']['id']; + } + + $this->assertNotEmpty($responseData['data']['id']); + + $this->assertDatabaseHas('payments', [ + 'id' => $responseData['data']['id'], + 'amount' => 2500, + 'currency' => 'BRL', + 'description' => 'Pagamento HTTP de integração', + 'status' => 'pending', + ]); + } + + public function testItRejectsInvalidPaymentDataWithMinimumAmount(): void + { + // Arrange + $payload = [ + 'amount' => 0, + 'currency' => 'BRL', + 'description' => 'Pagamento inválido', + 'status' => 'pending', + ]; + + // Act + $response = $this->post('/payments', $payload); + + // Assert + $response + ->assertStatus(422) + ->assertJson([ + 'success' => false, + 'error' => 'The payment amount must be greater than zero and less than or equal to 100000000.', + ]); + } + + public function testItRejectsInvalidPaymentDataWithMaximumAmount(): void + { + // Arrange + $payload = [ + 'amount' => 100000001, + 'currency' => 'BRL', + 'description' => 'Pagamento inválido', + 'status' => 'pending', + ]; + + // Act + $response = $this->post('/payments', $payload); + + // Assert + $response + ->assertStatus(422) + ->assertJson([ + 'success' => false, + 'error' => 'The payment amount must be greater than zero and less than or equal to 100000000.', + ]); + } + + public function testItRejectsInvalidPaymentDataWithInvalidCurrency(): void + { + // Arrange + $payload = [ + 'amount' => 2500, + 'currency' => 'INVALID', + 'description' => 'Pagamento inválido', + 'status' => 'pending', + ]; + + // Act + $response = $this->post('/payments', $payload); + + // Assert + $response + ->assertStatus(422) + ->assertJson([ + 'success' => false, + 'error' => 'The payment currency must be a valid ISO 4217 code.', + ]); + } + + public function testItRejectsInvalidPaymentDataWithInvalidStatus(): void + { + // Arrange + $payload = [ + 'amount' => 2500, + 'currency' => 'BRL', + 'description' => 'Pagamento inválido', + 'status' => 'invalid_status', + ]; + + // Act + $response = $this->post('/payments', $payload); + + // Assert + $response + ->assertStatus(422) + ->assertJson([ + 'success' => false, + 'error' => 'Invalid payment status: invalid_status. Valid statuses are: pending, paid, failed, canceled.', + ]); + } + + public function testItRejectsInvalidPaymentDataWithEmptyDescription(): void + { + // Arrange + $payload = [ + 'amount' => 2500, + 'currency' => 'BRL', + 'description' => ' ', + 'status' => 'pending', + ]; + + // Act + $response = $this->post('/payments', $payload); + + // Assert + $response + ->assertStatus(422) + ->assertJson([ + 'success' => false, + 'error' => 'The payment description cannot be empty.', + ]); + } +}