Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
32685e3
feat(domain): adiciona transitionTo() e expiresAt em Charge
lmoraesdev Jul 22, 2026
3a33eda
feat(charges): adiciona GET /charges/{id} com ChargeNotFoundException
lmoraesdev Jul 22, 2026
7bd935a
feat(charges): adiciona idempotência via header Idempotency-Key
lmoraesdev Jul 22, 2026
bcfd625
feat(webhooks): adiciona endpoint de webhook do provedor com transiçã…
lmoraesdev Jul 22, 2026
7b00a54
fix(webhooks): usa switch exaustivo para eventType do outbox, cobrind…
lmoraesdev Jul 22, 2026
e346367
fix(outbox): usa Logger5w1hBuilder no OutboxRelay
lmoraesdev Jul 23, 2026
a82fb9b
feat(outbox): SKIP LOCKED pra segurança multi-instância
lmoraesdev Jul 23, 2026
7fa5dab
feat(observability): métricas Micrometer customizadas pro Grafana
lmoraesdev Jul 23, 2026
5e8d787
test(architecture): ArchUnit trava pureza do domínio hexagonal
lmoraesdev Jul 23, 2026
8408344
feat(observability): propaga correlation_id do request original pro l…
lmoraesdev Jul 23, 2026
df2de53
fix(logging): Logger5w1h grava mensagem real em vez de "null"
lmoraesdev Jul 23, 2026
86e3709
test(logging): configura logback-test.xml pra stack trace curto (%wEx…
lmoraesdev Jul 23, 2026
cd9bc32
feat(charges): expira cobranças vencidas via job agendado com expires…
lmoraesdev Jul 23, 2026
6ea60af
fix(observability): reduz ruído de log (SQL/DDL/framework) nos testes
lmoraesdev Jul 23, 2026
20d659a
feat(persistence): migra schema de ddl-auto pra Flyway com migration V1
lmoraesdev Jul 23, 2026
0709fed
Merge pull request #12 from lmoraesdev/feature/charge-lifecycle-webhooks
lmoraesdev Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>

<!-- ── Cache (pre-installed, not used yet) ─────────────────────────── -->
<dependency>
Expand Down Expand Up @@ -118,6 +126,12 @@
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>1.3.0</version>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,45 @@
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;

@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<CreateChargeResponse> create(CreateChargeRequest request) {
CreateChargeResult result = createCharge.create(new CreateChargeCommand(request.amount()));
public ResponseEntity<CreateChargeResponse> 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<GetChargeResponse> get(UUID id) {
GetChargeResult result = getCharge.find(id);

GetChargeResponse response =
new GetChargeResponse(
result.id(), result.status(), result.amount(), result.createdAt());

return ResponseEntity.ok(response);
}
}
Original file line number Diff line number Diff line change
@@ -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) {}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;

Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Void> receive(WebhookProviderPayload payload) {
processWebhook.process(
new ProcessWebhookCommand(payload.eventId(), payload.chargeId(), payload.status()));

return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
@@ -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) {}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -27,19 +33,43 @@ 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",
description = "Erro interno inesperado",
content = @Content(schema = @Schema(implementation = ProblemDetail.class)))
})
@PostMapping
ResponseEntity<CreateChargeResponse> create(@Valid @RequestBody CreateChargeRequest request);
ResponseEntity<CreateChargeResponse> 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<GetChargeResponse> get(@PathVariable UUID id);
}
Original file line number Diff line number Diff line change
@@ -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<Void> receive(@Valid @RequestBody WebhookProviderPayload payload);
}
Loading
Loading