diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..621b693
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,18 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+indent_style = space
+indent_size = 4
+
+[*.{yml,yaml,json}]
+indent_size = 2
+
+[*.md]
+trim_trailing_whitespace = false
+
+[Makefile]
+indent_style = tab
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..4956686
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,27 @@
+# Copy this file to .env and fill in real values before running docker compose.
+# cp .env.example .env
+
+# ── PostgreSQL ─────────────────────────────────────────────────────────────────
+POSTGRES_USER=admin
+POSTGRES_PASSWORD=admin_pass
+POSTGRES_DB=payment_db
+
+# ── Grafana ────────────────────────────────────────────────────────────────────
+GRAFANA_ADMIN_USER=admin
+GRAFANA_ADMIN_PASSWORD=admin
+
+# ── OpenTelemetry / Jaeger ─────────────────────────────────────────────────────
+# Automatically set to http://jaeger:4318/v1/traces by docker-compose.yml.
+# Override here only if pointing to an external collector.
+# OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318/v1/traces
+
+# ── Redis (profile: cache) ─────────────────────────────────────────────────────
+# Redis only starts with: docker compose --profile cache up -d
+# SPRING_REDIS_HOST=redis
+# SPRING_REDIS_PORT=6379
+
+# ── Spring overrides (set automatically by docker-compose.yml) ─────────────────
+# SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/payment_db
+# SPRING_DATASOURCE_USERNAME=admin
+# SPRING_DATASOURCE_PASSWORD=admin_pass
+# SPRING_KAFKA_BOOTSTRAP_SERVERS=kafka:29092
diff --git a/.githooks/commit-msg b/.githooks/commit-msg
new file mode 100755
index 0000000..cb3aa72
--- /dev/null
+++ b/.githooks/commit-msg
@@ -0,0 +1,31 @@
+#!/bin/sh
+# Validates that the commit message follows Conventional Commits.
+# Enable with: git config core.hooksPath .githooks
+#
+# Format: tipo(escopo opcional): descrição
+# Tipos permitidos: feat, fix, docs, style, refactor, test, chore, build, ci, perf, revert
+
+COMMIT_MSG_FILE="$1"
+COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
+
+# Allow merge commits and fixup commits
+case "$COMMIT_MSG" in
+ Merge*|Revert*|fixup!*|squash!*) exit 0 ;;
+esac
+
+PATTERN="^(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)(\([a-zA-Z0-9/_-]+\))?: .{1,100}$"
+
+if ! echo "$COMMIT_MSG" | grep -qE "$PATTERN"; then
+ echo ""
+ echo " ✗ Commit message does not follow Conventional Commits."
+ echo ""
+ echo " Format : tipo(escopo): descrição"
+ echo " Example: feat(payment): add charge creation use case"
+ echo " Types : feat fix docs style refactor test chore build ci perf revert"
+ echo ""
+ echo " Your message: $COMMIT_MSG"
+ echo ""
+ exit 1
+fi
+
+exit 0
diff --git a/.githooks/pre-push b/.githooks/pre-push
new file mode 100755
index 0000000..b0a9fc2
--- /dev/null
+++ b/.githooks/pre-push
@@ -0,0 +1,8 @@
+#!/bin/sh
+# Run full Maven verify (unit + integration tests) before every push.
+# Enable with: git config core.hooksPath .githooks
+set -e
+
+echo ">>> pre-push: running ./mvnw verify ..."
+./mvnw verify
+echo ">>> pre-push: OK"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c10093e..5b143a7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,60 +1,60 @@
name: CI
on:
- pull_request:
- branches: ["main"]
push:
- branches: ["main"]
+ branches: [develop, main]
+ pull_request:
+ branches: [main]
permissions:
contents: read
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
- build:
- name: Build and Test
+ unit-tests:
+ name: Lint + Unit Tests
runs-on: ubuntu-latest
-
- services:
- postgres:
- image: postgres:15
- env:
- POSTGRES_DB: payment_db
- POSTGRES_USER: admin
- POSTGRES_PASSWORD: admin_pass
- ports:
- - 5432:5432
- options: >-
- --health-cmd="pg_isready -U admin -d payment_db"
- --health-interval=10s
- --health-timeout=5s
- --health-retries=5
-
- redis:
- image: redis:alpine
- ports:
- - 6379:6379
- options: >-
- --health-cmd="redis-cli ping"
- --health-interval=10s
- --health-timeout=5s
- --health-retries=5
-
- env:
- DB_HOST: localhost
- DB_NAME: payment_db
- DB_USER: admin
- DB_PASSWORD: admin_pass
+ timeout-minutes: 15
steps:
- - name: Checkout repository
- uses: actions/checkout@v4
+ - uses: actions/checkout@v4
- - name: Setup Java
- uses: actions/setup-java@v4
+ - uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: maven
- - name: Run tests
+ - name: Check formatting (Spotless)
+ run: ./mvnw spotless:check
+
+ - name: Check style (Checkstyle)
+ run: ./mvnw checkstyle:check
+
+ - name: Run unit tests
run: ./mvnw test
+
+ full-verify:
+ name: Full Verify + Docker Build
+ # Runs on pushes to main and on PRs targeting main
+ if: github.ref == 'refs/heads/main' || github.base_ref == 'main'
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: 21
+ cache: maven
+
+ - name: Run full verify (unit + integration tests)
+ run: ./mvnw verify
+
+ - name: Build Docker image
+ run: docker build -t payment-api:ci .
diff --git a/.gitignore b/.gitignore
index 667aaef..8f3304e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,10 @@ target/
!**/src/main/**/target/
!**/src/test/**/target/
+# Secrets — never commit real env file
+.env
+!.env.example
+
### STS ###
.apt_generated
.classpath
diff --git a/Dockerfile b/Dockerfile
index e69de29..3faa0b8 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -0,0 +1,26 @@
+# ── Build stage ────────────────────────────────────────────────────────────────
+FROM maven:3.9-eclipse-temurin-21 AS builder
+WORKDIR /app
+
+# Cache dependency layer: copy descriptor first, then source
+COPY pom.xml .
+RUN mvn dependency:go-offline -q
+
+COPY src ./src
+RUN mvn package -DskipTests -q
+
+# ── Runtime stage ───────────────────────────────────────────────────────────────
+FROM eclipse-temurin:21-jre-alpine
+RUN addgroup -S app && adduser -S app -G app
+WORKDIR /app
+
+COPY --from=builder /app/target/*.jar app.jar
+RUN chown app:app app.jar
+
+USER app
+EXPOSE 8080
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
+ CMD wget -qO- http://localhost:8080/actuator/health || exit 1
+
+ENTRYPOINT ["java", "-jar", "app.jar"]
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..6ab1acc
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,37 @@
+.PHONY: up down clean logs test verify format db help
+
+help:
+ @echo "Available targets:"
+ @echo " up - Build and start all services (docker compose up -d --build)"
+ @echo " down - Stop services, keep data volumes"
+ @echo " clean - Stop services and remove data volumes"
+ @echo " logs - Tail app service logs"
+ @echo " test - Run unit tests (no Docker required)"
+ @echo " verify - Run unit + integration tests (requires Docker)"
+ @echo " format - Apply Spotless formatter to all Java files"
+ @echo " db - Open psql shell in the running postgres container"
+
+up:
+ docker compose up -d --build
+
+down:
+ docker compose down
+
+clean:
+ docker compose down -v
+
+logs:
+ docker compose logs -f app
+
+test:
+ ./mvnw test
+
+verify:
+ ./mvnw verify
+
+format:
+ ./mvnw spotless:apply
+
+db:
+ @source .env 2>/dev/null || true; \
+ docker compose exec postgres psql -U "$${POSTGRES_USER}" "$${POSTGRES_DB}"
diff --git a/README.md b/README.md
index 44fe401..1f77926 100644
--- a/README.md
+++ b/README.md
@@ -1,119 +1,133 @@
-# 💳 Payment API
+# Payment API
-API de pagamentos desenvolvida em Java + Spring Boot, com foco em arquitetura limpa, logging estruturado e boas práticas de backend.
+API de pagamentos Pix em Java + Spring Boot com Clean Architecture, Kafka (KRaft), Postgres, Prometheus, Grafana e Jaeger.
----
+**Documentação de arquitetura:**
+- [docs/architecture.md](docs/architecture.md) — domínio, fluxos, API, pacotes, idempotência
+- [docs/devops-flow.md](docs/devops-flow.md) — pipeline CI/CD + observabilidade (Mermaid)
-## 🚀 Objetivo
+## Stack
-Simular um sistema real de pagamentos aplicando:
+| Camada | Tecnologia |
+|---|---|
+| Runtime | Java 21 LTS |
+| Framework | Spring Boot 3.5.3 |
+| Messaging | Kafka 3.9 (KRaft — sem Zookeeper) |
+| Persistence | PostgreSQL 18 |
+| Métricas | Actuator + Micrometer + Prometheus + Grafana |
+| Tracing | Micrometer Tracing + OpenTelemetry → Jaeger |
+| Cache | Redis 7 (pré-instalado, profile `cache`) |
+| Testes | JUnit 5 + Testcontainers |
+| Qualidade | Spotless (GJF AOSP) + Checkstyle |
-* Arquitetura em camadas
-* Logs estruturados (5W1H)
-* Tratamento global de erros
-* Boas práticas de versionamento
+## Quickstart
----
-
-## 🏗️ Arquitetura
-
-O sistema segue um fluxo baseado em CI/CD com separação clara entre aplicação, persistência e observabilidade.
-
-
+```bash
+# 1. Variáveis de ambiente
+cp .env.example .env
+# 2. Subir toda a infra + app
+make up
+# 3. Status
+docker compose ps
```
-src/main/java/dev/leandromoraes/paymentapi
-├── adapters
-│ └── in
-│ └── web
-├── application
-├── domain
-├── infrastructure
-│ └── logger
-└── presentation
-└── handler
-```
----
-## ⚙️ Tecnologias
+| URL | Descrição |
+|---|---|
+| `GET /ping` | Smoke test |
+| `GET /actuator/health` | Health probe |
+| `GET /actuator/prometheus` | Métricas Prometheus |
+| http://localhost:8090 | Kafka UI |
+| http://localhost:9090 | Prometheus |
+| http://localhost:3000 | Grafana — Payment Overview |
+| http://localhost:16686 | Jaeger — traces |
-* Java 21+
-* Spring Boot
-* Maven
-* PostgreSQL (Docker)
-* Redis (Docker)
+## Subir Redis (opcional)
----
-
-## 🐳 Infra local
+Redis está pré-instalado mas **não sobe por padrão**:
```bash
-docker-compose up -d
+docker compose --profile cache up -d
```
----
-
-## 📡 Endpoints
-
-### Health Check
+## Makefile
-```http
-GET /health
```
-
-Response:
-
-```json
-{
- "status": "UP"
-}
+make up # docker compose up -d --build
+make down # docker compose down (mantém volumes)
+make clean # docker compose down -v (apaga volumes)
+make logs # docker compose logs -f app
+make test # ./mvnw test (sem Docker)
+make verify # ./mvnw verify (com Testcontainers — requer Docker)
+make format # ./mvnw spotless:apply
+make db # psql no container postgres
```
----
+## Qualidade de código
-## 📊 Logging (5W1H)
-
-O projeto utiliza logs estruturados no padrão:
+```bash
+# Formatar
+./mvnw spotless:apply # ou: make format
-* where → onde aconteceu
-* why → por que aconteceu
-* when → quando aconteceu
-* who → quem causou
-* what → o que aconteceu
-* how → como aconteceu
+# Verificar formatação (GJF AOSP, 4-space)
+./mvnw spotless:check
-Exemplo:
+# Verificar estilo (Checkstyle)
+./mvnw checkstyle:check
-```json
-{
- "where": "HealthController",
- "what": "health_check",
- "why": "system_monitoring",
- "who": "system",
- "how": "GET /health",
- "when": "2026-05-04T16:00:00"
-}
+# CI roda os dois antes dos testes:
+./mvnw spotless:check && ./mvnw checkstyle:check && ./mvnw test
```
----
+## Pacotes (Clean Architecture)
+
+```
+com.lmoraesdev.payment
+├── adapter
+│ ├── in.web ← controllers, exception handler
+│ └── out
+│ ├── messaging ← Kafka producers (futuro)
+│ └── persistence ← JPA repositories (futuro)
+├── application
+│ ├── port.in ← interfaces de entrada (futuro)
+│ ├── port.out ← interfaces de saída (futuro)
+│ └── usecase ← casos de uso (futuro)
+├── config
+│ └── logging ← Logger5w1h estruturado
+└── domain
+ ├── event ← domain events (futuro)
+ ├── exception ← DomainException base
+ └── model ← entidades / value objects (futuro)
+```
-## ⚠️ Tratamento de Erros
+## Testes
-Implementado com `@RestControllerAdvice`, garantindo:
+```bash
+# Unitários (sem Docker)
+./mvnw test
-* respostas padronizadas
-* logging estruturado
-* desacoplamento da lógica de negócio
+# Integração — sobe Postgres 18 via Testcontainers
+./mvnw verify
+```
----
+## Git hooks
-## 🧪 Status
+```bash
+git config core.hooksPath .githooks
+```
-Em desenvolvimento
+| Hook | O que faz |
+|---|---|
+| `pre-push` | Executa `./mvnw verify` antes de cada push |
+| `commit-msg` | Valida formato Conventional Commits |
----
+Formato de commit: `tipo(escopo): descrição`
+Tipos: `feat fix docs style refactor test chore build ci perf revert`
-## 👨💻 Autor
+## CI
-
+| Branch / PR | Jobs |
+|---|---|
+| `develop` push | Lint (Spotless + Checkstyle) + unit tests |
+| `main` push / PR → main | Lint + unit tests + full-verify + docker build |
diff --git a/config/checkstyle.xml b/config/checkstyle.xml
new file mode 100644
index 0000000..f33f16b
--- /dev/null
+++ b/config/checkstyle.xml
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docker-compose.yml b/docker-compose.yml
index e412c13..d0b0f89 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,23 +1,169 @@
+# =============================================================
+# Subir TUDO com um comando:
+# cp .env.example .env
+# docker compose up -d --build
+#
+# Subir com Redis (profile cache):
+# docker compose --profile cache up -d --build
+#
+# Ver status: docker compose ps
+# Ver logs: docker compose logs -f app
+# Derrubar: docker compose down (mantém dados)
+# docker compose down -v (apaga dados)
+# =============================================================
+
services:
+
+ # -----------------------------------------------------------
+ # APP — Spring Boot, buildado pelo Dockerfile multi-stage.
+ # -----------------------------------------------------------
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: pay-app
+ depends_on:
+ postgres:
+ condition: service_healthy
+ kafka:
+ condition: service_started
+ jaeger:
+ condition: service_started
+ ports:
+ - "8080:8080"
+ environment:
+ SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB}
+ SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
+ SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
+ SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:29092
+ # Traces vão para o Jaeger via OTLP HTTP
+ OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4318/v1/traces
+
+ # -----------------------------------------------------------
+ # POSTGRES
+ # -----------------------------------------------------------
postgres:
- image: postgres:15
- container_name: payment_db
+ image: postgres:18-alpine
+ container_name: pay-postgres
environment:
- POSTGRES_DB: ${DB_NAME:-payment_db}
- POSTGRES_USER: ${DB_USER:-admin}
- POSTGRES_PASSWORD: ${DB_PASSWORD:-admin_pass}
+ POSTGRES_USER: ${POSTGRES_USER}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ POSTGRES_DB: ${POSTGRES_DB}
ports:
- "5432:5432"
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
+ interval: 5s
+ timeout: 3s
+ retries: 5
+
+ # -----------------------------------------------------------
+ # KAFKA — KRaft (sem Zookeeper). Listener duplo:
+ # INTERNAL kafka:29092 (rede docker) / EXTERNAL localhost:9092 (host)
+ # -----------------------------------------------------------
+ kafka:
+ image: apache/kafka:3.9.0
+ container_name: pay-kafka
+ ports:
+ - "9092:9092"
+ environment:
+ KAFKA_NODE_ID: 1
+ KAFKA_PROCESS_ROLES: broker,controller
+ KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
+ KAFKA_LISTENERS: INTERNAL://0.0.0.0:29092,EXTERNAL://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093
+ KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka:29092,EXTERNAL://localhost:9092
+ KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT
+ KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL
+ KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
+ KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
+ KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
+ KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
+ KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
+ volumes:
+ - kafkadata:/var/lib/kafka/data
+
+ # -----------------------------------------------------------
+ # KAFKA-UI
+ # -----------------------------------------------------------
+ kafka-ui:
+ image: provectuslabs/kafka-ui:latest
+ container_name: pay-kafka-ui
+ depends_on:
+ - kafka
+ ports:
+ - "8090:8080"
+ environment:
+ KAFKA_CLUSTERS_0_NAME: local
+ KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:29092
+ DYNAMIC_CONFIG_ENABLED: "true"
+
+ # -----------------------------------------------------------
+ # PROMETHEUS — raspa /actuator/prometheus do app.
+ # -----------------------------------------------------------
+ prometheus:
+ image: prom/prometheus:latest
+ container_name: pay-prometheus
+ depends_on:
+ - app
+ ports:
+ - "9090:9090"
+ volumes:
+ - ./infra/prometheus.yml:/etc/prometheus/prometheus.yml:ro
+
+ # -----------------------------------------------------------
+ # GRAFANA — datasource Prometheus já provisionado.
+ # -----------------------------------------------------------
+ grafana:
+ image: grafana/grafana:latest
+ container_name: pay-grafana
+ depends_on:
+ - prometheus
+ ports:
+ - "3000:3000"
+ environment:
+ GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER}
+ GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD}
+ GF_USERS_ALLOW_SIGN_UP: "false"
+ volumes:
+ - ./infra/grafana/provisioning:/etc/grafana/provisioning:ro
+ - grafanadata:/var/lib/grafana
+ # -----------------------------------------------------------
+ # JAEGER — all-in-one. Recebe OTLP HTTP (:4318) e expõe UI (:16686).
+ # -----------------------------------------------------------
+ jaeger:
+ image: jaegertracing/all-in-one:latest
+ container_name: pay-jaeger
+ ports:
+ - "16686:16686" # UI
+ - "4317:4317" # OTLP gRPC
+ - "4318:4318" # OTLP HTTP
+ environment:
+ COLLECTOR_OTLP_ENABLED: "true"
+
+ # -----------------------------------------------------------
+ # REDIS — só sobe com: docker compose --profile cache up -d
+ # Pré-instalado para idempotência/cache/lock futuro. Não usado ainda.
+ # -----------------------------------------------------------
redis:
- image: redis:alpine
- container_name: payment_cache
+ image: redis:7-alpine
+ container_name: pay-redis
+ profiles:
+ - cache
ports:
- "6379:6379"
+ volumes:
+ - redisdata:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 3s
+ retries: 5
- # Opcional: OpenTelemetry Collector (para centralizar os traces)
- otel-collector:
- image: otel/opentelemetry-collector:latest
- command: ["--config=/etc/otel-collector-config.yaml"]
- ports:
- - "4317:4317" # OTLP gRPC
+volumes:
+ pgdata:
+ kafkadata:
+ grafanadata:
+ redisdata:
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..acb128f
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,278 @@
+# Arquitetura — payment-api
+
+> Espelho do [pix-payment-core](https://github.com/lmoraesdev/pix-payment-core) em Java com
+> Clean Architecture pragmática. A lógica de negócio fica isolada no domínio; infraestrutura
+> e framework ficam nas bordas.
+
+---
+
+## Domínio: Charge (Cobrança Pix)
+
+### Entidade principal
+
+```
+Charge
+├── id : UUID (gerado na criação)
+├── idempotencyKey: String (único — cliente envia no header)
+├── amount : Money (value object — BigDecimal + "BRL")
+├── pixKey : String (chave Pix do recebedor)
+├── qrCode : String (gerado pelo provedor, retornado ao cliente)
+├── status : ChargeStatus (máquina de estados — ver abaixo)
+├── createdAt : Instant
+├── updatedAt : Instant
+└── expiresAt : Instant (TTL configurável, padrão 30 min)
+```
+
+### Value Object: Money
+
+```
+Money
+├── amount : BigDecimal (escala 2, arredondamento HALF_UP)
+└── currency: String (fixo "BRL" por ora)
+```
+
+### Máquina de estados: ChargeStatus
+
+```mermaid
+stateDiagram-v2
+ [*] --> CREATED : POST /charges
+
+ CREATED --> AWAITING_PAYMENT : enviado ao provedor Pix
+
+ AWAITING_PAYMENT --> PAID : webhook PAYMENT_CONFIRMED
+ AWAITING_PAYMENT --> EXPIRED : webhook PAYMENT_EXPIRED\nou TTL atingido
+
+ PAID --> [*]
+ EXPIRED --> [*]
+```
+
+> **Regra:** Não existe `setStatus()`. Apenas `transitionTo(newStatus)` com validação.
+> Qualquer transição inválida lança `InvalidStateTransitionException` (subtipo de `DomainException`).
+
+---
+
+## Fluxo de uma cobrança (sequência)
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant API as adapter.in.web
ChargeController
+ participant UC as application.usecase
CreateChargeUseCase
+ participant DOM as domain
Charge
+ participant REPO as adapter.out.persistence
ChargeRepository
+ participant PIX as adapter.out.provider
PixProviderPort
+ participant KFK as adapter.out.messaging
EventPublisher
+
+ Client->>+API: POST /charges
Idempotency-Key:
+ API->>+UC: CreateChargeCommand(key, amount, pixKey)
+
+ UC->>REPO: findByIdempotencyKey(key)
+ alt Já existe
+ REPO-->>UC: Charge existente
+ UC-->>API: ChargeResponse (idempotente)
+ API-->>Client: 200 OK (mesma resposta)
+ else Novo
+ UC->>DOM: Charge.create(id, key, amount, pixKey)
+ DOM-->>UC: Charge(status=CREATED)
+ UC->>PIX: generateQrCode(charge)
+ PIX-->>UC: qrCode
+ UC->>DOM: charge.transitionTo(AWAITING_PAYMENT)
+ UC->>REPO: save(charge)
+ UC-->>-API: ChargeResponse
+ API-->>-Client: 201 Created + qrCode
+ end
+```
+
+### Webhook do provedor (deduplicação)
+
+```mermaid
+sequenceDiagram
+ participant PIX as Provedor Pix
+ participant WH as adapter.in.web
WebhookController
+ participant UC as ProcessWebhookUseCase
+ participant REPO as ChargeRepository
+ participant KFK as EventPublisher
+
+ PIX->>+WH: POST /webhooks/provider
event_id, chargeId, status
+ WH->>+UC: ProcessWebhookCommand(eventId, chargeId, newStatus)
+
+ UC->>REPO: findByEventId(eventId)
+ alt Evento já processado
+ UC-->>WH: ignorado (idempotente)
+ else Novo evento
+ UC->>REPO: findById(chargeId)
+ REPO-->>UC: Charge(status=AWAITING_PAYMENT)
+ UC->>REPO: Domain: charge.transitionTo(PAID | EXPIRED)
+ UC->>REPO: save(charge) + markEventProcessed(eventId)
+ alt Status = PAID
+ UC->>KFK: publish(ChargePayedEvent)
+ end
+ end
+ WH-->>-PIX: 200 OK
+```
+
+---
+
+## Estrutura de pacotes
+
+```
+com.lmoraesdev.payment
+│
+├── domain/
+│ ├── model/
+│ │ ├── Charge.java ← entidade + transitionTo()
+│ │ ├── ChargeStatus.java ← enum com lógica de transição válida
+│ │ └── Money.java ← value object
+│ ├── event/
+│ │ └── ChargePayedEvent.java ← domain event publicado no Kafka
+│ └── exception/
+│ ├── DomainException.java ← base abstrata (já existe)
+│ ├── InvalidStateTransitionException.java
+│ └── ChargeNotFoundException.java
+│
+├── application/
+│ ├── usecase/
+│ │ ├── CreateChargeUseCase.java
+│ │ ├── ProcessWebhookUseCase.java
+│ │ └── GetChargeUseCase.java
+│ ├── port/
+│ │ ├── in/ ← interfaces que os controllers chamam
+│ │ │ ├── CreateChargePort.java
+│ │ │ ├── ProcessWebhookPort.java
+│ │ │ └── GetChargePort.java
+│ │ └── out/ ← interfaces que os use cases dependem
+│ │ ├── ChargeRepositoryPort.java
+│ │ ├── PixProviderPort.java
+│ │ └── EventPublisherPort.java
+│
+├── adapter/
+│ ├── in/
+│ │ └── web/
+│ │ ├── ChargeController.java ← POST /charges, GET /charges/{id}
+│ │ ├── WebhookController.java ← POST /webhooks/provider
+│ │ ├── GlobalExceptionHandler.java ← já existe
+│ │ ├── PingController.java ← smoke test, já existe
+│ │ └── dto/
+│ │ ├── CreateChargeRequest.java
+│ │ ├── ChargeResponse.java
+│ │ └── WebhookProviderPayload.java
+│ └── out/
+│ ├── persistence/
+│ │ ├── ChargeJpaRepository.java ← interface Spring Data JPA
+│ │ ├── ChargeRepositoryAdapter.java ← implementa ChargeRepositoryPort
+│ │ └── entity/
+│ │ └── ChargeEntity.java ← @Entity JPA, separado do domain
+│ └── messaging/
+│ └── KafkaEventPublisher.java ← implementa EventPublisherPort
+│
+└── config/
+ ├── logging/ ← Logger5w1h (já existe)
+ └── KafkaConfig.java ← topics, serializers (futuro)
+```
+
+---
+
+## Contratos de API
+
+### POST /charges
+```
+Headers:
+ Idempotency-Key: (obrigatório)
+ Content-Type: application/json
+
+Body:
+{
+ "amount": 150.00,
+ "pixKey": "leandro@email.com"
+}
+
+Response 201:
+{
+ "id": "uuid",
+ "status": "AWAITING_PAYMENT",
+ "qrCode": "00020126...",
+ "expiresAt": "2026-06-03T15:30:00Z"
+}
+
+Response 200: (mesma resposta se Idempotency-Key já foi usada)
+Response 422: transição de estado inválida ou valor inválido
+```
+
+### GET /charges/{id}
+```
+Response 200:
+{
+ "id": "uuid",
+ "status": "PAID | AWAITING_PAYMENT | EXPIRED | CREATED",
+ "amount": 150.00,
+ "pixKey": "leandro@email.com",
+ "createdAt": "...",
+ "updatedAt": "..."
+}
+
+Response 404: charge não encontrada
+```
+
+### POST /webhooks/provider
+```
+Body:
+{
+ "eventId": "uuid", (deduplicação)
+ "chargeId": "uuid",
+ "status": "PAYMENT_CONFIRMED | PAYMENT_EXPIRED"
+}
+
+Response 200: sempre, mesmo para eventos duplicados (idempotente)
+```
+
+---
+
+## Kafka — tópicos e eventos
+
+| Tópico | Evento | Publicado quando |
+|---|---|---|
+| `payments.charged` | `ChargePayedEvent` | Charge transiciona para `PAID` |
+
+```json
+// ChargePayedEvent
+{
+ "eventId": "uuid",
+ "chargeId": "uuid",
+ "amount": 150.00,
+ "pixKey": "leandro@email.com",
+ "occurredAt": "2026-06-03T15:00:00Z"
+}
+```
+
+---
+
+## Idempotência e Redis (futuro)
+
+Redis está pré-instalado (`docker compose --profile cache up -d`).
+Quando o domínio precisar:
+
+| Uso | Chave Redis | TTL |
+|---|---|---|
+| Lock de criação | `idempotency:lock:{key}` | 30s |
+| Cache de resposta | `idempotency:response:{key}` | 24h |
+| Deduplicação de webhook | `webhook:seen:{eventId}` | 7d |
+
+> Por enquanto a deduplicação de webhook usa constraint `UNIQUE(event_id)` no Postgres.
+> Redis entra quando precisar de lock distribuído (múltiplas instâncias do app).
+
+---
+
+## Observabilidade
+
+```
+Requisição HTTP
+ │
+ ├── Log estruturado (5W1H + traceId) ──────────────► stdout / futuramente Loki
+ │
+ ├── Métricas Micrometer ────────────────────────────► Prometheus ──► Grafana
+ │
+ └── Trace OpenTelemetry (OTLP HTTP) ───────────────► Jaeger :16686
+```
+
+Todo log já carrega o `traceId` via MDC (`%X{traceId}` no pattern).
+Com isso, é possível correlacionar log ↔ trace no Jaeger clicando num traceId.
diff --git a/docs/devops-flow.md b/docs/devops-flow.md
new file mode 100644
index 0000000..d64e038
--- /dev/null
+++ b/docs/devops-flow.md
@@ -0,0 +1,85 @@
+# DevOps Flow — payment-api
+
+> Fluxo de desenvolvimento, CI/CD e observabilidade do projeto.
+
+```mermaid
+flowchart LR
+ subgraph DEV["💻 Dev Local"]
+ SB["Spring Boot\n:8080"]
+ end
+
+ subgraph VCS["🔀 Git"]
+ BR["develop / main"]
+ end
+
+ subgraph CI["⚙️ CI — GitHub Actions"]
+ direction TB
+ LINT["Spotless + Checkstyle"]
+ UNIT["./mvnw test"]
+ IT["./mvnw verify\n(Testcontainers)"]
+ DOCKER["docker build"]
+ LINT --> UNIT --> IT --> DOCKER
+ end
+
+ subgraph REG["📦 Registry"]
+ IMG["Docker Image\nGHCR / DockerHub"]
+ end
+
+ subgraph CLOUD["☁️ Cloud Deploy"]
+ direction TB
+ DENV["Dev"]
+ STAGE["Staging"]
+ PROD["Prod"]
+ end
+
+ subgraph APP["🚀 API — Spring Boot"]
+ direction TB
+ CTRL["Controllers\nadapter.in.web"]
+ UC["Use Cases\napplication.usecase"]
+ DOM["Domain\nCharge + StateMachine"]
+ CTRL --> UC --> DOM
+ end
+
+ subgraph INFRA["🗄️ Infraestrutura"]
+ direction TB
+ PG["PostgreSQL 18\n(JPA / Hibernate)"]
+ KFK["Kafka KRaft\nChargePayedEvent"]
+ RDS["Redis 7\n(idempotência — futuro)"]
+ end
+
+ subgraph OBS["🔭 Observabilidade"]
+ direction TB
+ PROM["Prometheus\nmétrics"]
+ GRF["Grafana\ndashboard"]
+ JAEG["Jaeger\ntraces OTLP"]
+ LOG["Logs estruturados\n5W1H + traceId"]
+ PROM --> GRF
+ end
+
+ DEV -->|push| VCS
+ VCS -->|trigger| CI
+ CI -->|push image| REG
+ REG -->|pull & deploy| CLOUD
+ CLOUD --> APP
+ APP --> INFRA
+ APP --> OBS
+```
+
+---
+
+## Environments
+
+| Env | Branch | Gatilho |
+|---|---|---|
+| Dev | `develop` | push → unit tests + lint |
+| Staging / Prod | `main` | PR aprovado → full-verify + docker build |
+
+## Observabilidade: Logs + Métricas + Traces
+
+Toda requisição nasce com um **traceId** gerado pelo OpenTelemetry e propagado automaticamente:
+
+- **Logs** — `application.yml` injeta `%X{traceId}` em cada linha via MDC
+- **Métricas** — Micrometer → Prometheus → Grafana (dashboard `Payment — Overview`)
+- **Traces** — Micrometer Tracing → OTLP HTTP → Jaeger (`:16686`)
+
+> **Futuro:** Loki para log aggregation (completa o trio Logs + Métricas + Traces no Grafana).
diff --git a/docs/devops-flow.png.png b/docs/devops-flow.png
similarity index 100%
rename from docs/devops-flow.png.png
rename to docs/devops-flow.png
diff --git a/infra/grafana/provisioning/dashboards/payment-overview.json b/infra/grafana/provisioning/dashboards/payment-overview.json
new file mode 100644
index 0000000..c803f98
--- /dev/null
+++ b/infra/grafana/provisioning/dashboards/payment-overview.json
@@ -0,0 +1,65 @@
+{
+ "title": "Payment — Overview",
+ "uid": "payment-overview",
+ "schemaVersion": 38,
+ "version": 1,
+ "refresh": "30s",
+ "time": { "from": "now-1h", "to": "now" },
+ "panels": [
+ {
+ "id": 1,
+ "title": "Ping Rate (req/s)",
+ "type": "timeseries",
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
+ "datasource": { "uid": "prometheus", "type": "prometheus" },
+ "targets": [
+ {
+ "expr": "rate(http_server_requests_seconds_count{uri=\"/ping\"}[1m])",
+ "legendFormat": "ping req/s"
+ }
+ ]
+ },
+ {
+ "id": 2,
+ "title": "HTTP Request Rate (all endpoints)",
+ "type": "timeseries",
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
+ "datasource": { "uid": "prometheus", "type": "prometheus" },
+ "targets": [
+ {
+ "expr": "sum(rate(http_server_requests_seconds_count[1m])) by (uri, status)",
+ "legendFormat": "{{uri}} {{status}}"
+ }
+ ]
+ },
+ {
+ "id": 3,
+ "title": "JVM Heap Used (bytes)",
+ "type": "timeseries",
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
+ "datasource": { "uid": "prometheus", "type": "prometheus" },
+ "targets": [
+ {
+ "expr": "jvm_memory_used_bytes{area=\"heap\"}",
+ "legendFormat": "{{id}}"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": { "unit": "bytes" }
+ }
+ },
+ {
+ "id": 4,
+ "title": "HTTP 5xx Error Rate",
+ "type": "timeseries",
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
+ "datasource": { "uid": "prometheus", "type": "prometheus" },
+ "targets": [
+ {
+ "expr": "sum(rate(http_server_requests_seconds_count{status=~\"5..\"}[1m]))",
+ "legendFormat": "5xx/s"
+ }
+ ]
+ }
+ ]
+}
diff --git a/infra/grafana/provisioning/dashboards/provider.yml b/infra/grafana/provisioning/dashboards/provider.yml
new file mode 100644
index 0000000..a8e95b9
--- /dev/null
+++ b/infra/grafana/provisioning/dashboards/provider.yml
@@ -0,0 +1,10 @@
+apiVersion: 1
+
+providers:
+ - name: Payment Dashboards
+ folder: Payment
+ type: file
+ disableDeletion: false
+ updateIntervalSeconds: 30
+ options:
+ path: /etc/grafana/provisioning/dashboards
diff --git a/infra/grafana/provisioning/datasources/prometheus.yml b/infra/grafana/provisioning/datasources/prometheus.yml
new file mode 100644
index 0000000..04897dc
--- /dev/null
+++ b/infra/grafana/provisioning/datasources/prometheus.yml
@@ -0,0 +1,11 @@
+apiVersion: 1
+
+datasources:
+ - name: Prometheus
+ type: prometheus
+ uid: prometheus
+ url: http://prometheus:9090
+ isDefault: true
+ editable: false
+ jsonData:
+ timeInterval: 15s
diff --git a/infra/prometheus.yml b/infra/prometheus.yml
new file mode 100644
index 0000000..fce22c0
--- /dev/null
+++ b/infra/prometheus.yml
@@ -0,0 +1,9 @@
+global:
+ scrape_interval: 15s
+ evaluation_interval: 15s
+
+scrape_configs:
+ - job_name: payment-app
+ metrics_path: /actuator/prometheus
+ static_configs:
+ - targets: ['app:8080']
diff --git a/pom.xml b/pom.xml
index 710886d..a954345 100644
--- a/pom.xml
+++ b/pom.xml
@@ -2,90 +2,114 @@
4.0.0
+
org.springframework.boot
spring-boot-starter-parent
- 4.0.6
-
+ 3.5.3
+
- leandroDev
+
+ com.lmoraesdev
payment-api
0.0.1-SNAPSHOT
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ payment-api
+ Payment API — Clean Architecture, Spring Boot 3.5, Kafka, Postgres, OTel
+
21
+ 2.43.0
+ 3.5.0
+
+
org.springframework.boot
- spring-boot-starter-actuator
+ spring-boot-starter-web
org.springframework.boot
- spring-boot-starter-data-jpa
+ spring-boot-starter-validation
+
+
org.springframework.boot
- spring-boot-starter-validation
+ spring-boot-starter-data-jpa
+
+ org.postgresql
+ postgresql
+ runtime
+
+
+
org.springframework.boot
- spring-boot-starter-webmvc
+ spring-boot-starter-data-redis
+
+
- org.springdoc
- springdoc-openapi-starter-webmvc-ui
- 3.0.2
+ org.springframework.kafka
+ spring-kafka
+
- org.postgresql
- postgresql
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ io.micrometer
+ micrometer-registry-prometheus
runtime
+
+
+
+ io.micrometer
+ micrometer-tracing-bridge-otel
+
+
+ io.opentelemetry
+ opentelemetry-exporter-otlp
+
+
+
org.projectlombok
lombok
true
+
+
org.springframework.boot
- spring-boot-starter-actuator-test
+ spring-boot-starter-test
test
org.springframework.boot
- spring-boot-starter-data-jpa-test
+ spring-boot-testcontainers
test
- org.springframework.boot
- spring-boot-starter-validation-test
+ org.testcontainers
+ postgresql
test
- org.springframework.boot
- spring-boot-starter-webmvc-test
+ org.testcontainers
+ junit-jupiter
test
+
org.springframework.boot
spring-boot-maven-plugin
@@ -98,42 +122,67 @@
+
+
org.apache.maven.plugins
maven-compiler-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-failsafe-plugin
- default-compile
- compile
-
- compile
-
-
-
-
- org.projectlombok
- lombok
-
-
-
-
-
- default-testCompile
- test-compile
- testCompile
+ integration-test
+ verify
-
-
-
- org.projectlombok
- lombok
-
-
-
+
+
+
+
+ com.diffplug.spotless
+ spotless-maven-plugin
+ ${spotless.version}
+
+
+
+
+
+ false
+
+
+
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-checkstyle-plugin
+ ${checkstyle.version}
+
+ config/checkstyle.xml
+ true
+ true
+ true
+
+
diff --git a/src/main/java/dev/leandromoraes/paymentapi/PaymentApiApplication.java b/src/main/java/com/lmoraesdev/payment/PaymentApiApplication.java
similarity index 55%
rename from src/main/java/dev/leandromoraes/paymentapi/PaymentApiApplication.java
rename to src/main/java/com/lmoraesdev/payment/PaymentApiApplication.java
index 8f1f74a..946f3ab 100644
--- a/src/main/java/dev/leandromoraes/paymentapi/PaymentApiApplication.java
+++ b/src/main/java/com/lmoraesdev/payment/PaymentApiApplication.java
@@ -1,4 +1,4 @@
-package dev.leandromoraes.paymentapi;
+package com.lmoraesdev.payment;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -6,8 +6,7 @@
@SpringBootApplication
public class PaymentApiApplication {
- public static void main(String[] args) {
- SpringApplication.run(PaymentApiApplication.class, args);
- }
-
+ public static void main(String[] args) {
+ SpringApplication.run(PaymentApiApplication.class, args);
+ }
}
diff --git a/src/main/java/dev/leandromoraes/paymentapi/presentation/handler/GlobalExceptionHandler.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/GlobalExceptionHandler.java
similarity index 59%
rename from src/main/java/dev/leandromoraes/paymentapi/presentation/handler/GlobalExceptionHandler.java
rename to src/main/java/com/lmoraesdev/payment/adapter/in/web/GlobalExceptionHandler.java
index c80777d..65cee46 100644
--- a/src/main/java/dev/leandromoraes/paymentapi/presentation/handler/GlobalExceptionHandler.java
+++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/GlobalExceptionHandler.java
@@ -1,19 +1,18 @@
-package dev.leandromoraes.paymentapi.presentation.handler;
-
-import dev.leandromoraes.paymentapi.infrastructure.logger.Logger5w1hBuilder;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.*;
+package com.lmoraesdev.payment.adapter.in.web;
+import com.lmoraesdev.payment.config.logging.Logger5w1hBuilder;
import java.time.LocalDateTime;
import java.util.Map;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity