diff --git a/pom.xml b/pom.xml
index 4fe6da7..13054bf 100644
--- a/pom.xml
+++ b/pom.xml
@@ -43,6 +43,14 @@
postgresql
runtime
+
+ org.flywaydb
+ flyway-core
+
+
+ org.flywaydb
+ flyway-database-postgresql
+
@@ -118,6 +126,12 @@
junit-jupiter
test
+
+ com.tngtech.archunit
+ archunit-junit5
+ 1.3.0
+ test
+
diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/ChargeController.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/ChargeController.java
index 5d20dd5..de4f410 100644
--- a/src/main/java/com/lmoraesdev/payment/adapter/in/web/ChargeController.java
+++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/ChargeController.java
@@ -4,6 +4,9 @@
import com.lmoraesdev.payment.application.port.in.CreateCharge;
import com.lmoraesdev.payment.application.port.in.CreateChargeCommand;
import com.lmoraesdev.payment.application.port.in.CreateChargeResult;
+import com.lmoraesdev.payment.application.port.in.GetCharge;
+import com.lmoraesdev.payment.application.port.in.GetChargeResult;
+import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
@@ -11,19 +14,35 @@
@RestController
public class ChargeController implements ChargeControllerDoc {
private final CreateCharge createCharge;
+ private final GetCharge getCharge;
- public ChargeController(CreateCharge createCharge) {
+ public ChargeController(CreateCharge createCharge, GetCharge getCharge) {
this.createCharge = createCharge;
+ this.getCharge = getCharge;
}
@Override
- public ResponseEntity create(CreateChargeRequest request) {
- CreateChargeResult result = createCharge.create(new CreateChargeCommand(request.amount()));
+ public ResponseEntity create(
+ CreateChargeRequest request, String idempotencyKey) {
+ CreateChargeResult result =
+ createCharge.create(new CreateChargeCommand(request.amount(), idempotencyKey));
CreateChargeResponse response =
new CreateChargeResponse(
result.id(), result.status(), result.amount(), result.createdAt());
- return ResponseEntity.status(HttpStatus.CREATED).body(response);
+ HttpStatus status = result.replayed() ? HttpStatus.OK : HttpStatus.CREATED;
+ return ResponseEntity.status(status).body(response);
+ }
+
+ @Override
+ public ResponseEntity get(UUID id) {
+ GetChargeResult result = getCharge.find(id);
+
+ GetChargeResponse response =
+ new GetChargeResponse(
+ result.id(), result.status(), result.amount(), result.createdAt());
+
+ return ResponseEntity.ok(response);
}
}
diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/GetChargeResponse.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/GetChargeResponse.java
new file mode 100644
index 0000000..ef55339
--- /dev/null
+++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/GetChargeResponse.java
@@ -0,0 +1,13 @@
+package com.lmoraesdev.payment.adapter.in.web;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.util.UUID;
+
+@Schema(description = "Cobrança Pix")
+public record GetChargeResponse(
+ @Schema(description = "Identificador único da cobrança") UUID id,
+ @Schema(description = "Status da cobrança", example = "ACTIVE") String status,
+ @Schema(description = "Valor da cobrança em reais", example = "150.00") BigDecimal amount,
+ @Schema(description = "Data e hora de criação (UTC)") Instant createdAt) {}
diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/GlobalExceptionHandler.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/GlobalExceptionHandler.java
index df614e1..a557629 100644
--- a/src/main/java/com/lmoraesdev/payment/adapter/in/web/GlobalExceptionHandler.java
+++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/GlobalExceptionHandler.java
@@ -1,6 +1,7 @@
package com.lmoraesdev.payment.adapter.in.web;
import com.lmoraesdev.payment.config.logging.Logger5w1hBuilder;
+import com.lmoraesdev.payment.domain.exception.ChargeNotFoundException;
import com.lmoraesdev.payment.domain.exception.DomainException;
import java.util.HashMap;
import java.util.Map;
@@ -10,6 +11,7 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@@ -33,6 +35,28 @@ public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
return problem;
}
+ // 400 — header obrigatório ausente. Erro esperado do cliente: NÃO loga.
+ @ExceptionHandler(MissingRequestHeaderException.class)
+ public ProblemDetail handleMissingHeader(MissingRequestHeaderException ex) {
+ ProblemDetail problem =
+ ProblemDetail.forStatusAndDetail(
+ HttpStatus.BAD_REQUEST,
+ "Header obrigatório ausente: %s".formatted(ex.getHeaderName()));
+ problem.setTitle("Missing required header");
+ addTraceId(problem);
+ return problem;
+ }
+
+ // 404 — recurso não encontrado. Esperado: NÃO loga.
+ @ExceptionHandler(ChargeNotFoundException.class)
+ public ProblemDetail handleChargeNotFound(ChargeNotFoundException ex) {
+ ProblemDetail problem =
+ ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
+ problem.setTitle("Charge not found");
+ addTraceId(problem);
+ return problem;
+ }
+
// 422 — regra de negócio. Também esperado: NÃO loga.
@ExceptionHandler(DomainException.class)
public ProblemDetail handleDomain(DomainException ex) {
diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/WebhookController.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/WebhookController.java
new file mode 100644
index 0000000..bf8767e
--- /dev/null
+++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/WebhookController.java
@@ -0,0 +1,24 @@
+package com.lmoraesdev.payment.adapter.in.web;
+
+import com.lmoraesdev.payment.adapter.in.web.documentation.WebhookControllerDoc;
+import com.lmoraesdev.payment.application.port.in.ProcessWebhook;
+import com.lmoraesdev.payment.application.port.in.ProcessWebhookCommand;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+public class WebhookController implements WebhookControllerDoc {
+ private final ProcessWebhook processWebhook;
+
+ public WebhookController(ProcessWebhook processWebhook) {
+ this.processWebhook = processWebhook;
+ }
+
+ @Override
+ public ResponseEntity receive(WebhookProviderPayload payload) {
+ processWebhook.process(
+ new ProcessWebhookCommand(payload.eventId(), payload.chargeId(), payload.status()));
+
+ return ResponseEntity.ok().build();
+ }
+}
diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/WebhookProviderPayload.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/WebhookProviderPayload.java
new file mode 100644
index 0000000..8af04ff
--- /dev/null
+++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/WebhookProviderPayload.java
@@ -0,0 +1,19 @@
+package com.lmoraesdev.payment.adapter.in.web;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import java.util.UUID;
+
+@Schema(description = "Payload de webhook recebido do provedor Pix")
+public record WebhookProviderPayload(
+ @Schema(description = "Identificador único do evento, usado para deduplicação") @NotBlank
+ String eventId,
+ @Schema(description = "Identificador da cobrança") @NotNull UUID chargeId,
+ @Schema(description = "Novo status da cobrança", example = "PAID")
+ @NotBlank
+ @Pattern(
+ regexp = "PAID|EXPIRED|CANCELLED",
+ message = "status deve ser PAID, EXPIRED ou CANCELLED")
+ String status) {}
diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/documentation/ChargeControllerDoc.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/documentation/ChargeControllerDoc.java
index c4fdc93..a6ff69c 100644
--- a/src/main/java/com/lmoraesdev/payment/adapter/in/web/documentation/ChargeControllerDoc.java
+++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/documentation/ChargeControllerDoc.java
@@ -2,17 +2,23 @@
import com.lmoraesdev.payment.adapter.in.web.CreateChargeRequest;
import com.lmoraesdev.payment.adapter.in.web.CreateChargeResponse;
+import com.lmoraesdev.payment.adapter.in.web.GetChargeResponse;
import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
+import java.util.UUID;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
@Tag(name = "Charges", description = "Gerenciamento de cobranças Pix")
@@ -27,13 +33,19 @@ public interface ChargeControllerDoc {
responseCode = "201",
description = "Cobrança criada com sucesso",
content = @Content(schema = @Schema(implementation = CreateChargeResponse.class))),
+ @ApiResponse(
+ responseCode = "200",
+ description =
+ "Idempotency-Key já utilizada com o mesmo corpo — cobrança existente retornada",
+ content = @Content(schema = @Schema(implementation = CreateChargeResponse.class))),
@ApiResponse(
responseCode = "400",
- description = "Dados inválidos — Problem Details com erros por campo",
+ description = "Dados inválidos ou header Idempotency-Key ausente — Problem Details",
content = @Content(schema = @Schema(implementation = ProblemDetail.class))),
@ApiResponse(
responseCode = "422",
- description = "Regra de negócio violada",
+ description =
+ "Regra de negócio violada ou Idempotency-Key reutilizada com corpo diferente",
content = @Content(schema = @Schema(implementation = ProblemDetail.class))),
@ApiResponse(
responseCode = "500",
@@ -41,5 +53,23 @@ public interface ChargeControllerDoc {
content = @Content(schema = @Schema(implementation = ProblemDetail.class)))
})
@PostMapping
- ResponseEntity create(@Valid @RequestBody CreateChargeRequest request);
+ ResponseEntity create(
+ @Valid @RequestBody CreateChargeRequest request,
+ @Parameter(description = "Chave de idempotência do cliente", required = true)
+ @RequestHeader("Idempotency-Key")
+ String idempotencyKey);
+
+ @Operation(summary = "Consultar cobrança", description = "Busca uma cobrança Pix pelo id")
+ @ApiResponses({
+ @ApiResponse(
+ responseCode = "200",
+ description = "Cobrança encontrada",
+ content = @Content(schema = @Schema(implementation = GetChargeResponse.class))),
+ @ApiResponse(
+ responseCode = "404",
+ description = "Cobrança não encontrada",
+ content = @Content(schema = @Schema(implementation = ProblemDetail.class)))
+ })
+ @GetMapping("/{id}")
+ ResponseEntity get(@PathVariable UUID id);
}
diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/documentation/WebhookControllerDoc.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/documentation/WebhookControllerDoc.java
new file mode 100644
index 0000000..3f3c987
--- /dev/null
+++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/documentation/WebhookControllerDoc.java
@@ -0,0 +1,45 @@
+package com.lmoraesdev.payment.adapter.in.web.documentation;
+
+import com.lmoraesdev.payment.adapter.in.web.WebhookProviderPayload;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import org.springframework.http.ProblemDetail;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+@Tag(name = "Webhooks", description = "Recebimento de eventos do provedor Pix")
+@RequestMapping("/webhooks")
+public interface WebhookControllerDoc {
+
+ @Operation(
+ summary = "Receber webhook do provedor",
+ description =
+ "Processa uma notificação de mudança de status da cobrança. Sempre retorna 200,"
+ + " inclusive para eventos duplicados (idempotente por eventId)")
+ @ApiResponses({
+ @ApiResponse(
+ responseCode = "200",
+ description = "Webhook processado (ou já processado anteriormente)"),
+ @ApiResponse(
+ responseCode = "400",
+ description = "Payload inválido — Problem Details com erros por campo",
+ content = @Content(schema = @Schema(implementation = ProblemDetail.class))),
+ @ApiResponse(
+ responseCode = "404",
+ description = "Cobrança não encontrada",
+ content = @Content(schema = @Schema(implementation = ProblemDetail.class))),
+ @ApiResponse(
+ responseCode = "422",
+ description = "Transição de status inválida para o estado atual da cobrança",
+ content = @Content(schema = @Schema(implementation = ProblemDetail.class)))
+ })
+ @PostMapping("/provider")
+ ResponseEntity receive(@Valid @RequestBody WebhookProviderPayload payload);
+}
diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/messaging/OutboxRelay.java b/src/main/java/com/lmoraesdev/payment/adapter/out/messaging/OutboxRelay.java
index 0b686fb..1e07e06 100644
--- a/src/main/java/com/lmoraesdev/payment/adapter/out/messaging/OutboxRelay.java
+++ b/src/main/java/com/lmoraesdev/payment/adapter/out/messaging/OutboxRelay.java
@@ -2,11 +2,15 @@
import com.lmoraesdev.payment.adapter.out.persistence.outbox.OutboxEventEntity;
import com.lmoraesdev.payment.adapter.out.persistence.outbox.OutboxEventJpaRepository;
-import com.lmoraesdev.payment.adapter.out.persistence.outbox.OutboxStatus;
+import com.lmoraesdev.payment.config.logging.Logger5w1hBuilder;
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Timer;
+import java.time.Duration;
+import java.time.Instant;
import java.util.List;
import java.util.UUID;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+import org.slf4j.MDC;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@@ -15,34 +19,71 @@
@Component
public class OutboxRelay {
- private static final Logger LOG = LoggerFactory.getLogger(OutboxRelay.class);
private static final String TOPIC = "payments.charge-created";
private final OutboxEventJpaRepository repository;
private final KafkaTemplate