diff --git a/README.md b/README.md index e21cbde..37bec1e 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,15 @@ # payment-api -Core de pagamentos Pix em Java — Hexagonal Architecture, observabilidade e pipeline CI/CD. EPIC-001 (criar cobrança) implementado e testado. +Core de pagamentos Pix em Java, com Arquitetura Hexagonal. O foco não é só criar e consultar +cobranças — é fazer isso com garantias reais de concorrência e consistência (idempotência, +outbox transacional, optimistic locking) e observabilidade de verdade (logs estruturados 5W1H, +correlation_id de ponta a ponta, métricas e tracing distribuído). Não é "mais um CRUD de +pagamento". [![CI](https://github.com/lmoraesdev/java-payment-core/actions/workflows/ci.yml/badge.svg)](https://github.com/lmoraesdev/java-payment-core/actions/workflows/ci.yml) ![Java](https://img.shields.io/badge/Java-21-blue?logo=openjdk&logoColor=white) ![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5.3-6DB33F?logo=springboot&logoColor=white) -## Status - -| EPIC | Descrição | Status | -|---|---|---| -| EPIC-001 | Criar cobrança — domínio, persistência, REST, tratamento de erros, testes | ✅ Concluído | -| EPIC-002 | Buscar cobrança (`GET /charges/{id}`) + idempotência | 🔲 Roadmap | -| EPIC-003 | Publicar `ChargeCreated` no Kafka | 🔲 Roadmap | -| EPIC-008 | Flyway — migrações de schema versionadas | 🔲 Roadmap | - ## Stack | Tecnologia | Versão | Para quê | @@ -23,168 +18,162 @@ Core de pagamentos Pix em Java — Hexagonal Architecture, observabilidade e pip | Spring Boot | 3.5.3 | Framework web, DI, auto-configuração | | PostgreSQL | 18 | Persistência principal | | Kafka (KRaft) | 3.9 | Event streaming — sem Zookeeper | -| Redis | 7 | Cache / idempotência (pré-instalado, profile `cache`) | -| SpringDoc OpenAPI | 2.8.17 | Swagger UI + spec OpenAPI 3 | -| Prometheus + Grafana | latest | Métricas + dashboard Payment Overview pré-provisionado | -| Jaeger + OpenTelemetry | latest | Distributed tracing via OTLP HTTP | -| Testcontainers | 1.21 | Testes de integração com PostgreSQL 18 real | +| Flyway | (via Spring Boot) | Migrações de schema versionadas (`V1`–`V5`) | +| Redis | 7 | Pré-instalado (profile `cache`), sem uso ainda | +| Micrometer + Prometheus | — | Métricas de negócio e técnicas | +| Micrometer Tracing + OpenTelemetry (OTLP) | — | Tracing distribuído, exportado pro Jaeger | +| ArchUnit | 1.3.0 | Trava em teste que o domínio não depende de framework | +| Testcontainers | (Spring Boot BOM) | Integration tests com PostgreSQL 18 real via Docker | +| JaCoCo | 0.8.13 | Cobertura de testes | | Spotless (GJF AOSP) | 2.43 | Formatação automática de código | | Checkstyle | 3.5 | Verificação de estilo | +| SpringDoc OpenAPI | 2.8.17 | Swagger UI + spec OpenAPI 3 | ## Arquitetura -O projeto segue Arquitetura Hexagonal (Ports & Adapters): o domínio não conhece Spring, JPA nem Kafka. Frameworks e infraestrutura ficam nas bordas; a lógica de negócio fica isolada e testável sem container. +Ports & Adapters: o domínio (`domain/**`) não conhece Spring, JPA nem qualquer outro framework. +Um teste ArchUnit ([`DomainPurityArchTest`](src/test/java/com/lmoraesdev/payment/architecture/DomainPurityArchTest.java)) +garante isso em CI — quebra o build se alguma classe em `domain/**` importar +`org.springframework..` ou `jakarta.persistence..`. ``` com.lmoraesdev.payment ├── adapter -│ ├── in.web ← ChargeController, GlobalExceptionHandler, DTOs -│ └── out.persistence ← ChargeJpaEntity, ChargeMapper, ChargeRepositoryAdapter +│ ├── in.web ← controllers REST, DTOs, GlobalExceptionHandler, docs OpenAPI +│ ├── out.messaging ← OutboxRelay (publica no Kafka) e OutboxClaimCoordinator (transacional) +│ ├── out.persistence ← entidades JPA e adapters de Charge, outbox, idempotência e webhook +│ └── out.scheduling ← ChargeExpirationJob e ChargeExpirationCoordinator ├── application -│ ├── port.in ← CreateCharge (interface), CreateChargeCommand, CreateChargeResult -│ ├── port.out ← ChargeRepository (interface) -│ └── usecase ← CreateChargeService +│ ├── port.in ← casos de uso (interfaces) + commands/results +│ ├── port.out ← portas de saída (repositórios, outbox, idempotência) +│ └── usecase ← implementação dos casos de uso ├── config -│ ├── logging ← Log5w1h, Logger5w1hBuilder (structured 5W1H logging) +│ ├── logging ← Log5w1h, Logger5w1hBuilder (logging estruturado 5W1H) │ └── OpenApiConfig ← SpringDoc / Swagger UI └── domain - ├── exception ← DomainException (base), InvalidAmountException - └── model ← Charge, Money, ChargeStatus + ├── event ← eventos de domínio (payload do outbox) + ├── exception ← DomainException e subclasses, mapeadas a status HTTP + └── model ← Charge, Money, ChargeStatus — zero dependência de framework ``` -Decisões de projeto: -- **Domínio puro** — `Charge`, `Money`, `ChargeStatus` sem nenhuma anotação de framework -- **Armazenamento monetário em centavos** — `amount_centavos BIGINT` no banco; `Money` normaliza para scale=2 no domínio; o mapper converte nos dois sentidos. Elimina risco de ponto flutuante em operações financeiras. -- **Erros tipados** — `InvalidAmountException extends DomainException` → 422; genéricos → 500 -- **Logging estratégico** — só o use case loga o evento de negócio (`charge_created`); controller e adapters não logam (OTel/Jaeger cobre o fluxo) -- **Problem Details (RFC 9457)** — todos os erros retornam `ProblemDetail` com `traceId` -- **Observabilidade desde o início** — Prometheus, Grafana e Jaeger na infra antes do primeiro use case +## Padrões implementados + +**Transactional Outbox com `SELECT ... FOR UPDATE SKIP LOCKED`.** Gravar a `Charge` e o evento de +domínio na mesma transação evita dual-write (perder o evento se o Kafka cair depois do commit). +`SKIP LOCKED` em `OutboxEventJpaRepository.findBatchForUpdateSkipLocked()` permite rodar múltiplas +instâncias do relay em paralelo sem duas instâncias publicarem o mesmo evento. As operações de +claim/publish/revert vivem em `OutboxClaimCoordinator`, um bean separado de `OutboxRelay` — se +estivessem na mesma classe chamando `this.metodo()`, o proxy do Spring nunca seria interceptado e o +`@Transactional` viraria no-op silencioso. Eventos que ficam presos em `IN_FLIGHT` por mais de 2 +minutos (worker morreu no meio do processamento) são reclamados de volta pra `PENDING` antes do +próximo lote. + +**Idempotency-Key com replay seguro sob concorrência.** `POST /charges` exige o header +`Idempotency-Key`; uma chave repetida com o mesmo corpo retorna a cobrança já criada (200) em vez +de duplicar. O caso interessante é a race: duas requisições com a mesma chave *nova* chegam ao +mesmo tempo, nenhuma encontra registro existente, e uma delas comita primeiro. A perdedora esbarra +na constraint única de `idempotency_records` — no Postgres, isso aborta a transação inteira, então +não dá pra simplesmente capturar a exceção e continuar consultando nela. `CreateChargeService` +captura essa violação **fora** da transação de escrita (que já rodou em `ChargeCreationCoordinator` +e sofreu rollback completo) e só então busca o registro da vencedora numa transação nova, +devolvendo o replay dela em vez de um 500 cru. Provado com um teste de concorrência real +(duas threads, mesma chave, Postgres via Testcontainers — não dá pra provar isso com mocks). + +**Optimistic locking (`@Version`) no ciclo de vida da `Charge`.** Um webhook do provedor e o job de +expiração agendado podem tentar transicionar a mesma `Charge` ao mesmo tempo. `ChargeJpaEntity` +implementa `Persistable` com `isNew() = version == null` — como o id é um UUID gerado em +código (não pelo banco), o Spring Data não teria como distinguir insert de update só pelo id, e +sem isso todo `save()` de uma charge nova viraria um `merge()` que falha com um optimistic lock +falso. Num conflito real, o job de expiração pula aquela charge nesse ciclo sem travar as outras +(cada uma processada em sua própria transação, via `ChargeExpirationCoordinator`); o caminho do +webhook deixa a exceção propagar até um handler dedicado que responde 409, para o provedor tentar +de novo. + +**Deduplicação de webhook por `event_id`.** Constraint única em `webhook_events.event_id`; +`ProcessWebhookService` verifica isso antes de tocar em qualquer coisa, então reenvios do provedor +(comuns em integrações de pagamento) são no-ops seguros, não erros. + +**Logging estruturado 5W1H com `correlation_id` propagado via MDC.** Em vez de mensagens de log +livres, todo evento relevante passa por `Logger5w1hBuilder` (`where`/`why`/`who`/`what`/`how`). O +`correlation_id` da requisição original é persistido junto com o evento do outbox e restaurado no +MDC quando o relay loga uma falha de publicação — mesmo atravessando o boundary assíncrono entre +"criar a cobrança" e "publicar no Kafka minutos depois", a falha continua rastreável até a +requisição que a originou. + +## Endpoints + +| Método | Path | Request | Response | +|---|---|---|---| +| `POST` | `/charges` | Header `Idempotency-Key` + `{"amount": 150.00}` | `201` (ou `200` em replay) com `{id, status, amount, createdAt}` | +| `GET` | `/charges/{id}` | — | `200` com `{id, status, amount, createdAt}`, `404` se não existir | +| `POST` | `/webhooks/provider` | `{"eventId", "chargeId", "status"}` (`status` ∈ `PAID`\|`EXPIRED`\|`CANCELLED`) | `200` sempre que processado (inclusive reenvio duplicado), `404`/`422`/`409` conforme o caso | + +Erros seguem Problem Details (RFC 9457) — `ProblemDetail` com `traceId` no corpo. ## Como rodar -**Pré-requisitos:** Docker Desktop com WSL2 integration habilitada; contexto Docker configurado para `default`. +**Pré-requisito:** Docker com suporte a Compose. ```bash -# 1. Variáveis de ambiente cp .env.example .env +docker compose up -d --build # ou: make up -# 2. Subir infra + app -make up - -# 3. Subir com Redis (profile cache) +# com Redis (profile cache, ainda sem uso pela aplicação): docker compose --profile cache up -d --build ``` -## API +Alternativa sem Docker para o app (com Postgres e Kafka já rodando localmente nas portas padrão +`5432`/`9092`, usuário/banco `admin`/`payment_db` batendo com `.env.example`): -### Criar cobrança +```bash +./mvnw spring-boot:run +``` -```http -POST /charges -Content-Type: application/json +## Testes -{"amount": 150.00} +```bash +./mvnw test # unitários — sem Docker, ~poucos segundos +./mvnw verify # unitários + integração — sobe PostgreSQL 18 real via Testcontainers ``` -**201 Created** -```json -{ - "id": "550e8400-e29b-41d4-a716-446655440000", - "status": "ACTIVE", - "amount": 150.00, - "createdAt": "2025-06-05T18:00:00Z" -} -``` +Convenção: `*Test.java` roda no Surefire (unitário, mocka os ports); `*IT.java` roda no Failsafe +com Testcontainers (integração, banco real). Alguns exemplos do que cada tipo cobre: -**400 Bad Request** (amount inválido) -```json -{ - "status": 400, - "title": "Validation failed", - "detail": "Um ou mais campos são inválidos", - "errors": { "amount": "must be greater than 0" }, - "traceId": "abc123..." -} -``` +| Teste | O que prova | +|---|---| +| `CreateChargeServiceConcurrencyIT` | Duas requisições concorrentes com a mesma Idempotency-Key nova geram uma única charge; a perdedora recebe replay, não 500 | +| `OutboxClaimCoordinatorTransactionalIT` | Uma exceção no meio do claim do outbox desfaz a mudança de status (prova que o `@Transactional` funciona de verdade, não é self-invocation) | +| `ChargeExpirationJobTest` / `ChargeExpirationCoordinatorTest` | Conflito de optimistic locking numa charge não impede as outras de expirarem no mesmo ciclo | +| `ProcessWebhookServiceTest` | Status inválido é rejeitado antes de qualquer transição; conflito de lock propaga em vez de ser engolido | +| `ChargeRepositoryIT` | Round-trip save/findById preserva todos os campos, inclusive `version` | +| `DomainPurityArchTest` | `domain/**` livre de imports de Spring/JPA | + +Relatório de cobertura JaCoCo em `target/site/jacoco/index.html` após `./mvnw verify`. -## Endpoints e observabilidade +## Observabilidade e Swagger (local) | URL | O que se vê | |---|---| | `http://localhost:8080/swagger-ui.html` | Swagger UI — documentação interativa da API | | `http://localhost:8080/v3/api-docs` | Spec OpenAPI 3 em JSON | -| `http://localhost:8080/ping` | `{"status":"pong"}` — smoke test | | `http://localhost:8080/actuator/health` | Status do app, banco e dependências | | `http://localhost:8080/actuator/prometheus` | Métricas no formato Prometheus | -| `http://localhost:9090` | Prometheus — séries temporais, targets ativos | +| `http://localhost:9090` | Prometheus | | `http://localhost:3000` | Grafana — dashboard "Payment Overview" (credenciais do `.env`) | | `http://localhost:16686` | Jaeger — traces distribuídos por operação | | `http://localhost:8090` | Kafka UI — tópicos, consumer groups, mensagens | -## Testes - -```bash -# Unitários — sem Docker, rápido (~2s) -./mvnw test - -# Integração + cobertura — sobe PostgreSQL 18 via Testcontainers -./mvnw verify -``` - -| Teste | Tipo | O que cobre | -|---|---|---| -| `MoneyTest` | Unit | Validação de amount (7 casos table-driven) | -| `ChargeTest` | Unit | `create()`, `restore()`, `equals/hashCode` | -| `CreateChargeServiceTest` | Unit | Sucesso (3 valores) + erros de validação | -| `ChargeRepositoryIT` | Integration | Round-trip save/findById com PostgreSQL 18 real | -| `ChargeControllerIT` | Integration | POST 201, POST 400 Problem Details | - -Convenção de nomes: -- `*Test.java` — unitários, Surefire -- `*IT.java` — integração, Failsafe + Testcontainers - -Relatório JaCoCo gerado em `target/site/jacoco/index.html` após `./mvnw verify`. - -## CI/CD - -| Trigger | Job | O que roda | -|---|---|---| -| Push para `epic/**`, `develop` ou `main` | Lint + Unit Tests | `spotless:check` → `checkstyle:check` → `mvnw test` | -| Push para `main` ou PR → `main` | Full Verify + Docker Build | `mvnw verify` (unit + integração) → `docker build` | - -## Padrões +## Qualidade e commits ```bash -./mvnw spotless:apply # formata (Google Java Format, AOSP 4-space) -./mvnw spotless:check # verifica (roda no CI) -./mvnw checkstyle:check # estilo (roda no CI) +./mvnw spotless:apply # formata (Google Java Format, AOSP 4-space) +./mvnw spotless:check # verifica (roda no CI) +./mvnw checkstyle:check # estilo (roda no CI) ``` -**Git hooks** — shell scripts em `.githooks/` (ativar uma vez por clone): -```bash -git config core.hooksPath .githooks -``` - -| Hook | Ação | -|---|---| -| `commit-msg` | Valida formato Conventional Commits | -| `pre-push` | Executa `./mvnw verify` antes de subir | - -Formato de commit: `tipo(escopo): descrição` — tipos: `feat fix docs style refactor test chore build ci perf revert`. - -> **Nota:** os hooks são scripts shell nativos (`.githooks/`). Husky está previsto para substituí-los em versão futura. - -**Makefile:** -``` -make up # docker compose up -d --build -make down # docker compose down -make clean # docker compose down -v (remove volumes) -make logs # docker compose logs -f app -make test # ./mvnw test -make verify # ./mvnw verify -make format # ./mvnw spotless:apply -make db # psql no container postgres -``` +Git hooks nativos em `.githooks/` (ativar uma vez por clone com +`git config core.hooksPath .githooks`): `commit-msg` valida Conventional Commits +(`tipo(escopo): descrição`), `pre-push` roda `./mvnw verify` antes de subir. ---