Skip to content

douglasaledeoliveira/dok

Repository files navigation

Vehicle Debts API

API hexagonal em PHP 8.3+ para consulta e simulação de pagamento de débitos veiculares. Construída sobre Slim 4 + PHP-DI, segue DDD-lite, Ports & Adapters, SOLID e Clean Code. PHPStan max, Pint (PSR-12) e PHPUnit cobrindo todos os cenários de borda.


Sumário


Arquitetura

Quatro camadas, com dependências apontando sempre para dentro:

Presentation  →  Application  →  Domain  ←  Infrastructure
  • Domain: regras puras de negócio (juros, pagamentos, validações). Define ports (interfaces).
  • Application: orquestra casos de uso, manipula DTOs imutáveis.
  • Infrastructure: implementa adapters (HTTP, parsers, clock, log, retry, circuit breaker).
  • Presentation: HTTP/JSON via Slim 4 + middlewares PSR-15.

Padrões aplicados

Padrão Onde
Adapter ProviderAClient/ProviderBClient + JsonResponseParser/XmlResponseParser
Strategy InterestCalculator (IPVA, Multa) + InterestCalculatorRegistry
Decorator ResilientProvider envolvendo qualquer VehicleDebtProvider com retry + CB
Chain of Responsibility ProviderOrchestrator (fallback)
Value Object Plate, Money, DueDate, DebtType, PaymentScope, ...
DTO imutável ConsultDebtsInput/Output, ProviderResponse, RawDebtData
Factory PaymentOptionsFactory
Clock Provider Clock interface + FixedClock/SystemClock
Registry InterestCalculatorRegistry
PSR-15 Middleware CorrelationId, PayloadSize, JsonBodyParser, ErrorHandler

Estrutura de pastas

src/
├── Domain/
│   ├── Clock/                            Clock interface
│   ├── Shared/ValueObject/Money.php      BCMath, HALF_UP, escala 10 interna
│   ├── Vehicle/                          Plate VO + InvalidPlate exception
│   ├── Debt/
│   │   ├── Entity/Debt.php
│   │   ├── ValueObject/{DebtType, DueDate}
│   │   ├── Strategy/{InterestCalculator, IpvaInterestCalculator, FineInterestCalculator, InterestCalculatorRegistry}
│   │   ├── Service/{DebtUpdater, UpdatedDebt}
│   │   └── Exception/{UnknownDebtType, InvalidDueDate, NoInterestCalculatorForType}
│   ├── Payment/
│   │   ├── Method/{PaymentMethod, PixPaymentMethod, CreditCardPaymentMethod}
│   │   ├── Service/PaymentOptionsFactory.php
│   │   └── ValueObject/{PaymentScope, PaymentOption, PaymentMethodQuote, Installment}
│   └── Provider/
│       ├── Contract/VehicleDebtProvider.php
│       ├── ValueObject/{ProviderResponse, RawDebtData}
│       └── Exception/{ProviderUnavailable, AllProvidersUnavailable, CircuitOpen}
├── Application/
│   ├── DTO/                              ConsultDebtsInput/Output, DebtOutputData, ...
│   ├── Service/{ProviderOrchestrator, CanonicalDebtMapper}
│   └── UseCase/ConsultDebts/ConsultDebtsUseCase.php
├── Infrastructure/
│   ├── Clock/{SystemClock, FixedClock}
│   ├── Http/{HttpClient, GuzzleHttpClient}
│   ├── Logging/PlateMasker.php
│   ├── Config/AppConfig.php
│   ├── Provider/
│   │   ├── ProviderA/{ProviderAClient, JsonResponseParser}
│   │   └── ProviderB/{ProviderBClient, XmlResponseParser}
│   └── Resilience/{RetryPolicy, CircuitBreaker, ResilientProvider, Sleeper, UsleepSleeper}
├── Presentation/
│   └── Http/{Controller, Middleware, Request, Response, Exception}
└── Bootstrap/{ContainerFactory, AppFactory}

tests/
├── Unit/          (53 testes)
├── Integration/   (15 testes - fallback, HTTP E2E, use case completo)
└── Support/       FakeHttpClient, NullSleeper

Como executar

Localmente (PHP 8.3+)

composer install
cp .env.example .env
make serve              # ou: php -S 0.0.0.0:8881 -t public public/index.php

A API sobe em http://localhost:8881.

Docker

make docker-build
make docker-up
# logs:  docker compose logs -f api
# parar: make docker-down

Smoke test (sem providers reais)

CLOCK_FIXED_AT=2024-05-10T00:00:00Z php scripts/smoke-test.php

Saída esperada (recortada):

{
  "placa": "ABC1234",
  "debitos": [
    {"tipo":"IPVA","valor_original":"1500.00","valor_atualizado":"1800.00","vencimento":"2024-01-10","dias_atraso":121},
    {"tipo":"MULTA","valor_original":"300.50","valor_atualizado":"555.93","vencimento":"2024-02-15","dias_atraso":85}
  ],
  "resumo": {"total_original":"1800.50","total_atualizado":"2355.93"},
  "pagamentos": {"opcoes":[ ... TOTAL, SOMENTE_IPVA, SOMENTE_MULTA ... ]}
}

Como rodar testes

make test           # PHPUnit
make stan           # PHPStan max
make lint           # Pint --test
make ci             # lint + stan + test

Resultado esperado:

  • 68 testes / 143 assertions OK
  • PHPStan max — sem erros
  • Pint passed

Como validar os cenários de borda

Todos cobertos por testes automatizados em tests/:

Cenário Local
Cálculo de juros IPVA (com teto 20%) tests/Unit/Domain/Debt/InterestCalculatorsTest.php::test_ipva_interest_capped
Cálculo de juros IPVA abaixo do teto ::test_ipva_interest_below_cap
Cálculo de juros MULTA sem teto ::test_fine_interest_uncapped
Débito não vencido (dias_atraso ≤ 0) ::test_ipva_zero_when_not_overdue, ::test_fine_zero_when_not_overdue
Tipo de débito desconhecido → HTTP 422 tests/Integration/HttpEndpointTest.php::test_unknown_debt_type_returns_422
Parser JSON (Provider A) tests/Unit/Infrastructure/Provider/JsonResponseParserTest.php
Parser XML (Provider B) tests/Unit/Infrastructure/Provider/XmlResponseParserTest.php
XML autofechado <debts/> XmlResponseParserTest::test_supports_self_closed_empty_debts_element
Validação de placa (Mercosul + antigo) tests/Unit/Domain/Vehicle/PlateTest.php
Arredondamento HALF_UP tests/Unit/Domain/Shared/MoneyTest.php::test_rounding_half_up
PMT (1x/6x/12x) tests/Unit/Domain/Payment/CreditCardPaymentMethodTest.php
PIX (5% desconto) tests/Unit/Domain/Payment/PixPaymentMethodTest.php
Geração TOTAL / SOMENTE_TIPO tests/Unit/Domain/Payment/PaymentOptionsFactoryTest.php
Fallback automático entre providers tests/Integration/ProviderFallbackTest.php::test_falls_back_to_second_provider_when_first_fails
Todos providers falham → HTTP 503 ::test_throws_when_all_providers_fail + HttpEndpointTest::test_all_providers_unavailable_returns_503
Placa inválida → HTTP 400 HttpEndpointTest::test_invalid_plate_returns_400
Campos desconhecidos → HTTP 400 HttpEndpointTest::test_unknown_fields_return_400
Healthcheck HttpEndpointTest::test_healthcheck_endpoint
Mascaramento de placa nos logs (LGPD) tests/Unit/Infrastructure/Logging/PlateMaskerTest.php

Decisões arquiteturais

1. Estratégia de divergência entre providers: primeiro provider bem-sucedido vence

Documentada explicitamente no ProviderOrchestrator. Justificativa:

  • Previsibilidade: zero ambiguidade sobre qual valor é "verdadeiro".
  • Latência baixa: não esperamos todos os providers.
  • Compatível com Circuit Breaker: provider quebrado é pulado.
  • Alternativas descartadas:
    • Quorum/voting: requer regra de tie-break não definida no escopo.
    • Merge por prioridade: aumenta acoplamento entre adapters e mistura "verdades" externas.

2. Política monetária com BCMath

  • Escala interna 10 para acumular sem perda composta.
  • Escala de saída 2 com HALF_UP implementado manualmente (BCMath não tem HALF_UP nativo).
  • Toda operação monetária passa por Money VO — float é proibido em cálculos.
  • Saída JSON sempre string.

3. Clock Provider abstraído

  • Clock interface no Domain.
  • FixedClock (padrão de produção neste exercício, fixado em 2024-05-10T00:00:00Z) e SystemClock.
  • Tornar tempo injetável é pré-requisito de testabilidade — sem isso, juros não são determinísticos.

4. Strategy + Registry para juros

  • Adicionar tipo novo de débito = adicionar 1 enum + 1 calculator + 1 binding DI. Zero if/else espalhado.
  • InterestCalculatorRegistry::for(DebtType) lança NoInterestCalculatorForTypeException (LogicException — bug de programação, não erro de cliente).

5. Decorator de resiliência

ResilientProvider envolve qualquer VehicleDebtProvider:

  1. Verifica circuit breaker (closed/open/half_open).
  2. Tenta até RETRY_MAX_ATTEMPTS com backoff exponencial + jitter.
  3. Registra sucesso/falha no CB.

Isso permite plugar resiliência sem alterar o adapter.

6. Modelo canônico estrito

  • RawDebtData (transporte do provider) → Debt (domínio).
  • CanonicalDebtMapper é o único ponto que converte. Domínio nunca vê JSON/XML.

7. Segurança & LGPD

  • PayloadSizeMiddleware (1 MiB) antes de JsonBodyParser — evita parsear payloads grandes.
  • ConsultDebtsRequest rejeita campos desconhecidos.
  • PlateMasker aplica ABC***4 em logs estruturados.
  • Correlation-Id aceito do cliente ou gerado (UUID v4).

8. Erros estruturados

Mapeados em ErrorHandlerMiddleware:

Exceção HTTP Body
InvalidPlateException 400 {"error":"invalid_plate"}
BadRequestException (unknown_fields) 400 {"error":"unknown_fields"}
UnknownDebtTypeException 422 {"error":"unknown_debt_type","type":"X"}
AllProvidersUnavailableException 503 {"error":"all_providers_unavailable"}
Throwable (fallback) 500 {"error":"internal_server_error"}

Trade-offs

Decisão Trade-off
Slim 4 + PHP-DI (não Laravel/Symfony) Menos magia, mais código de wiring, mas core ~30 classes. Latência mínima.
Circuit breaker in-memory Funciona perfeitamente em processo CLI/PHP-FPM por request. Para múltiplas réplicas seria necessário Redis ou similar.
FixedClock como default Atende o requisito mas exige CLOCK_MODE=system para produção real. Trade explícito por testabilidade.
Sem cache Não solicitado pelo escopo; ports já existem (HttpClient, providers como interface) para inserir cache decorator no futuro.
Sem autenticação Fora de escopo; middleware adicional triviamente plugável (PSR-15).
assert() no Container Para destravar PHPStan max sem perder segurança em produção; pode ser desligado via zend.assertions=-1.
HALF_UP manual em BCMath Mais verboso, mas conformidade total com a regra "HALF_UP, 2 casas".
PMT cobra parcela arredondada × N Pode haver discrepância de 1 centavo entre valor_parcela × n e valor_total (esperado em PMT). Mantemos precisão interna alta para minimizar.

Como adicionar um novo provedor

  1. Crie Infrastructure/Provider/ProviderC/.
  2. Implemente um ProviderCClient implements VehicleDebtProvider e seu parser (SomeResponseParser) que devolve ProviderResponse canônico.
  3. Registre no ContainerFactory:
    ProviderCClient::class => static function (ContainerInterface $c): ProviderCClient { /* ... */ },
  4. Adicione C ao registro dentro de 'providers.ordered'.
  5. Atualize PROVIDER_ORDER=A,B,C no .env.

Zero alteração em Domain, Application, Use Case, regras de juros, regras de pagamento.

Como adicionar um novo tipo de débito

  1. Adicione o valor ao enum Domain\Debt\ValueObject\DebtType.
  2. Crie Domain\Debt\Strategy\XInterestCalculator implements InterestCalculator.
  3. Registre no InterestCalculatorRegistry (via ContainerFactory).
  4. Zero alteração em Debt, DebtUpdater, providers, parsers, controllers.

Endpoints

Método Rota Descrição
GET /healthcheck Status + versão
POST /v1/debts Consulta débitos por placa

Especificação completa em openapi.yaml.


Variáveis de ambiente

Veja .env.example. Principais:

Variável Default Descrição
CLOCK_MODE fixed fixed ou system
CLOCK_FIXED_AT 2024-05-10T00:00:00Z Instante UTC (ISO 8601)
PROVIDER_ORDER A,B Ordem de fallback
RETRY_MAX_ATTEMPTS 3 Tentativas por provider
CIRCUIT_BREAKER_FAILURE_THRESHOLD 5 Falhas até abrir CB
CIRCUIT_BREAKER_OPEN_SECONDS 30 Tempo em open
MAX_PAYLOAD_BYTES 1048576 Limite de payload (1 MiB)

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages