From 7fc291e57f19368df9d55277406bd8f37cfbe93c Mon Sep 17 00:00:00 2001 From: Leandro Moraes <84927698+lmoraesdev@users.noreply.github.com> Date: Mon, 4 May 2026 18:50:13 -0300 Subject: [PATCH 01/38] Rename devops-flow.png.png to devops-flow.png --- docs/{devops-flow.png.png => devops-flow.png} | Bin 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/{devops-flow.png.png => devops-flow.png} (100%) 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 From 8ef055e01b7dcf77d0c923a801cd7d239df6cc90 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Wed, 3 Jun 2026 14:10:55 -0300 Subject: [PATCH 02/38] refactor(logging): migrate to Spring Boot structured logging (ECS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace manual Jackson serialization with SLF4J 2.0 key-value API and Spring Boot 3.4+ native ECS encoder. Each log line is now a proper JSON object — no more JSON-inside-text. - Log5w1h: remove traceId, spanId, when (handled by encoder/MDC); change who/what from Object to String to prevent accidental PII leaks - Logger5w1h: drop ObjectMapper, use logger.atLevel().addKeyValue() API - Logger5w1hBuilder: remove when field/setter and LocalDateTime capture; remove Object overloads for who/what - application.yml: replace pattern.console with logging.structured.format.console=ecs --- .../payment/config/logging/Log5w1h.java | 10 +------ .../payment/config/logging/Logger5w1h.java | 26 +++++++++---------- .../config/logging/Logger5w1hBuilder.java | 26 +++++-------------- src/main/resources/application.yml | 5 ++-- 4 files changed, 23 insertions(+), 44 deletions(-) diff --git a/src/main/java/com/lmoraesdev/payment/config/logging/Log5w1h.java b/src/main/java/com/lmoraesdev/payment/config/logging/Log5w1h.java index cb4bbbf..8256c02 100644 --- a/src/main/java/com/lmoraesdev/payment/config/logging/Log5w1h.java +++ b/src/main/java/com/lmoraesdev/payment/config/logging/Log5w1h.java @@ -1,11 +1,3 @@ package com.lmoraesdev.payment.config.logging; -public record Log5w1h( - String traceId, - String spanId, - String where, - String why, - String when, - Object who, - Object what, - String how) {} +public record Log5w1h(String where, String why, String who, String what, String how) {} diff --git a/src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1h.java b/src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1h.java index 350f5f2..031b531 100644 --- a/src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1h.java +++ b/src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1h.java @@ -1,13 +1,11 @@ package com.lmoraesdev.payment.config.logging; -import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.slf4j.spi.LoggingEventBuilder; public class Logger5w1h { - private static final ObjectMapper MAPPER = new ObjectMapper(); - private final Logger logger; private Logger5w1h(Class clazz) { @@ -19,26 +17,26 @@ public static Logger5w1h of(Class clazz) { } public void info(Log5w1h data) { - logger.info(toJson(data)); + withFields(logger.atInfo(), data).log(); } public void debug(Log5w1h data) { - logger.debug(toJson(data)); + withFields(logger.atDebug(), data).log(); } public void warn(Log5w1h data) { - logger.warn(toJson(data)); + withFields(logger.atWarn(), data).log(); } - public void error(Log5w1h data, Throwable exception) { - logger.error(toJson(data), exception); + public void error(Log5w1h data, Throwable cause) { + withFields(logger.atError(), data).setCause(cause).log(); } - private String toJson(Log5w1h data) { - try { - return MAPPER.writeValueAsString(data); - } catch (Exception exception) { - return data.toString(); - } + private LoggingEventBuilder withFields(LoggingEventBuilder builder, Log5w1h data) { + return builder.addKeyValue("where", data.where()) + .addKeyValue("why", data.why()) + .addKeyValue("who", data.who()) + .addKeyValue("what", data.what()) + .addKeyValue("how", data.how()); } } diff --git a/src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1hBuilder.java b/src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1hBuilder.java index 2cd43c5..9db2d5f 100644 --- a/src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1hBuilder.java +++ b/src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1hBuilder.java @@ -1,22 +1,17 @@ package com.lmoraesdev.payment.config.logging; -import java.time.LocalDateTime; -import org.slf4j.MDC; - public class Logger5w1hBuilder { private final Logger5w1h logger; private String where; private String why; - private String when; - private Object who; - private Object what; + private String who; + private String what; private String how; private Logger5w1hBuilder(Class clazz) { this.logger = Logger5w1h.of(clazz); - this.when = LocalDateTime.now().toString(); } public static Logger5w1hBuilder create(Class clazz) { @@ -33,17 +28,12 @@ public Logger5w1hBuilder why(String value) { return this; } - public Logger5w1hBuilder when(String value) { - this.when = value; - return this; - } - - public Logger5w1hBuilder who(Object value) { + public Logger5w1hBuilder who(String value) { this.who = value; return this; } - public Logger5w1hBuilder what(Object value) { + public Logger5w1hBuilder what(String value) { this.what = value; return this; } @@ -65,13 +55,11 @@ public void warn() { logger.warn(build()); } - public void error(Throwable e) { - logger.error(build(), e); + public void error(Throwable cause) { + logger.error(build(), cause); } private Log5w1h build() { - // traceId is injected automatically from Micrometer Tracing MDC — - // no caller needs to pass it explicitly. - return new Log5w1h(MDC.get("traceId"), MDC.get("spanId"), where, why, when, who, what, how); + return new Log5w1h(where, why, who, what, how); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b9b966e..7d5da4c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -48,5 +48,6 @@ logging: level: root: INFO com.lmoraesdev.payment: DEBUG - pattern: - console: "%d{yyyy-MM-dd HH:mm:ss} %-5level [%thread] %X{traceId} %logger{36} - %msg%n" + structured: + format: + console: ecs From 0b215987d33dfb751fb929bf4cecb4585e010cb4 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Wed, 3 Jun 2026 22:34:54 -0300 Subject: [PATCH 03/38] feat(domain): add Money value object with validation --- .../lmoraesdev/payment/domain/model/Money.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/main/java/com/lmoraesdev/payment/domain/model/Money.java diff --git a/src/main/java/com/lmoraesdev/payment/domain/model/Money.java b/src/main/java/com/lmoraesdev/payment/domain/model/Money.java new file mode 100644 index 0000000..84dadc3 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/domain/model/Money.java @@ -0,0 +1,15 @@ +package com.lmoraesdev.payment.domain.model; + +import java.math.BigDecimal; +import java.util.Objects; + +public record Money(BigDecimal amount) { + public Money{ + Objects.requireNonNull(amount, "O valor (amount) não pode ser nulo"); + + if(amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("O valor monetário deve ser estritamente maior que zero"); + } + } +} + From 08596b125c582a12292b85d7b8bfe406e1cf3dcc Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Wed, 3 Jun 2026 22:35:01 -0300 Subject: [PATCH 04/38] feat(domain): add ChargeStatus enum --- .../lmoraesdev/payment/domain/model/ChargeStatus.java | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/main/java/com/lmoraesdev/payment/domain/model/ChargeStatus.java diff --git a/src/main/java/com/lmoraesdev/payment/domain/model/ChargeStatus.java b/src/main/java/com/lmoraesdev/payment/domain/model/ChargeStatus.java new file mode 100644 index 0000000..cec6d18 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/domain/model/ChargeStatus.java @@ -0,0 +1,9 @@ +package com.lmoraesdev.payment.domain.model; + +public enum ChargeStatus { + PENDING, + ACTIVE, + PAID, + EXPIRED, + CANCELLED +} From dc7d8b1487a90a32c546346a88c9a0dabfaf3215 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Wed, 3 Jun 2026 22:35:08 -0300 Subject: [PATCH 05/38] feat(domain): add Charge entity with create factory and id-based equals --- .../payment/domain/model/Charge.java | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/main/java/com/lmoraesdev/payment/domain/model/Charge.java diff --git a/src/main/java/com/lmoraesdev/payment/domain/model/Charge.java b/src/main/java/com/lmoraesdev/payment/domain/model/Charge.java new file mode 100644 index 0000000..eae6362 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/domain/model/Charge.java @@ -0,0 +1,67 @@ +package com.lmoraesdev.payment.domain.model; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +public class Charge { + private final UUID id; + private final Money amount; + private ChargeStatus status; + private final Instant createdAt; + + private Charge(UUID id, Money amount, ChargeStatus status, Instant createdAt){ + this.id = id; + this.amount = amount; + this.status = status; + this.createdAt = createdAt; + } + + public static Charge create(Money amount){ + Objects.requireNonNull(amount, "O montante (Money) é obrigatório"); + + return new Charge( + UUID.randomUUID(), + amount, + ChargeStatus.ACTIVE, + Instant.now() + ); + } + + public static Charge restore(UUID id, Money amount, ChargeStatus status, Instant createdAt) { + Objects.requireNonNull(id, "O id é obrigatório"); + Objects.requireNonNull(amount, "O montante (Money) é obrigatório"); + Objects.requireNonNull(status, "O status é obrigatório"); + Objects.requireNonNull(createdAt, "A data de criação é obrigatória"); + + return new Charge(id, amount, status, createdAt); + } + + public UUID getId() { + return id; + } + + public Money getAmount() { + return amount; + } + + public ChargeStatus getStatus() { + return status; + } + + public Instant getCreatedAt() { + return createdAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Charge other)) return false; + return id.equals(other.id); + } + + @Override + public int hashCode() { + return id.hashCode(); + } +} From 7f98dda43bcc6b6f0b0695702933bf0cb0d6c122 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Wed, 3 Jun 2026 22:35:21 -0300 Subject: [PATCH 06/38] feat(persistence): add ChargeRepository output port --- .../application/port/out/ChargeRepository.java | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/main/java/com/lmoraesdev/payment/application/port/out/ChargeRepository.java diff --git a/src/main/java/com/lmoraesdev/payment/application/port/out/ChargeRepository.java b/src/main/java/com/lmoraesdev/payment/application/port/out/ChargeRepository.java new file mode 100644 index 0000000..b03f95c --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/application/port/out/ChargeRepository.java @@ -0,0 +1,11 @@ +package com.lmoraesdev.payment.application.port.out; + +import java.util.Optional; +import java.util.UUID; + +import com.lmoraesdev.payment.domain.model.Charge; + +public interface ChargeRepository { + Charge save(Charge charge); + Optional findById(UUID id); +} From 8f9e97a6c09bdbb6fdf6144ef800c47c59966990 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Wed, 3 Jun 2026 22:38:11 -0300 Subject: [PATCH 07/38] feat(persistence): add ChargeJpaEntity mapped to charges table --- .../out/persistence/ChargeJpaEntity.java | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeJpaEntity.java diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeJpaEntity.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeJpaEntity.java new file mode 100644 index 0000000..6398748 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeJpaEntity.java @@ -0,0 +1,61 @@ +package com.lmoraesdev.payment.adapter.out.persistence; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +import com.lmoraesdev.payment.domain.model.ChargeStatus; + +import jakarta.persistence.*; + +@Entity +@Table(name = "charges") +public class ChargeJpaEntity { + @Id + private UUID id; + + @Column(nullable = false) + private BigDecimal amount; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ChargeStatus status; + + @Column(nullable = false) + private Instant createdAt; + + protected ChargeJpaEntity(){} + + public UUID getId(){ + return id; + } + + public void setId(UUID id){ + this.id = id; + } + + public BigDecimal getAmount(){ + return amount; + } + + public void setAmount(BigDecimal amount){ + this.amount = amount; + } + + public ChargeStatus getStatus() { + return status; + } + + public void setStatus(ChargeStatus status){ + this.status = status; + } + + public Instant getCreatedAt(){ + return createdAt; + } + + public void setCreatedAt(Instant createdAt){ + this.createdAt = createdAt; + } + +} From 3542c78345acf505b99e0e79015168fb23bb1776 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Wed, 3 Jun 2026 22:38:15 -0300 Subject: [PATCH 08/38] feat(persistence): add Spring Data JPA repository --- .../out/persistence/SpringDataChargeRepository.java | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/main/java/com/lmoraesdev/payment/adapter/out/persistence/SpringDataChargeRepository.java diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/SpringDataChargeRepository.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/SpringDataChargeRepository.java new file mode 100644 index 0000000..39127da --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/SpringDataChargeRepository.java @@ -0,0 +1,9 @@ +package com.lmoraesdev.payment.adapter.out.persistence; + +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SpringDataChargeRepository + extends JpaRepository{ +} From 9eb468b0119ae9dadbee2a84fec8d689f180fb23 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Wed, 3 Jun 2026 22:38:24 -0300 Subject: [PATCH 09/38] feat(persistence): add ChargeMapper between domain and entity --- .../adapter/out/persistence/ChargeMapper.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeMapper.java diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeMapper.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeMapper.java new file mode 100644 index 0000000..5ae2985 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeMapper.java @@ -0,0 +1,29 @@ +package com.lmoraesdev.payment.adapter.out.persistence; + +import com.lmoraesdev.payment.domain.model.Charge; +import com.lmoraesdev.payment.domain.model.Money; + +final class ChargeMapper { +private ChargeMapper(){} + + static ChargeJpaEntity toEntity(Charge charge){ + ChargeJpaEntity entity = new ChargeJpaEntity(); + + entity.setId(charge.getId()); + entity.setAmount(charge.getAmount().amount()); + entity.setStatus(charge.getStatus()); + entity.setCreatedAt(charge.getCreatedAt()); + + return entity; + } + + static Charge toDomain(ChargeJpaEntity entity){ + + return Charge.restore( + entity.getId(), + new Money(entity.getAmount()), + entity.getStatus(), + entity.getCreatedAt() + ); + } +} From 8affaeb7a405f673be3fddac3b1a5da0667729ba Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Wed, 3 Jun 2026 22:38:24 -0300 Subject: [PATCH 10/38] feat(persistence): implement ChargeRepository via JPA adapter --- .../persistence/ChargeRepositoryAdapter.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryAdapter.java diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryAdapter.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryAdapter.java new file mode 100644 index 0000000..01303de --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryAdapter.java @@ -0,0 +1,33 @@ +package com.lmoraesdev.payment.adapter.out.persistence; + +import com.lmoraesdev.payment.application.port.out.ChargeRepository; +import com.lmoraesdev.payment.domain.model.Charge; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.UUID; + +@Component +public class ChargeRepositoryAdapter implements ChargeRepository{ + private final SpringDataChargeRepository repository; + + public ChargeRepositoryAdapter(SpringDataChargeRepository repository){ + this.repository = repository; + } + + @Override + public Charge save(Charge charge){ + ChargeJpaEntity entity = ChargeMapper.toEntity(charge); + + ChargeJpaEntity saved = repository.save(entity); + + return ChargeMapper.toDomain(saved); + } + + @Override + public Optional findById(UUID id){ + return repository.findById(id) + .map(ChargeMapper::toDomain); + } + +} From 941957c2a2771cb44300379ddf1cad96c4ae921a Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Wed, 3 Jun 2026 22:38:24 -0300 Subject: [PATCH 11/38] chore(config): tune CI, ECS logging, OTLP tracing and disable redis repos --- .github/workflows/ci.yml | 2 +- src/main/resources/application.yml | 13 +++++-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b143a7..b93539e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [develop, main] + branches: ["epic/**", develop, main] pull_request: branches: [main] diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7d5da4c..a1b4698 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -5,7 +5,7 @@ spring: password: ${SPRING_DATASOURCE_PASSWORD:admin_pass} jpa: hibernate: - ddl-auto: none + ddl-auto: update open-in-view: false properties: hibernate: @@ -22,6 +22,8 @@ spring: value-serializer: org.apache.kafka.common.serialization.StringSerializer data: redis: + repositories: + enabled: false host: ${SPRING_REDIS_HOST:localhost} port: ${SPRING_REDIS_PORT:6379} @@ -38,16 +40,11 @@ management: tracing: sampling: probability: 1.0 - opentelemetry: + otlp: tracing: - export: - otlp: - endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318/v1/traces} + endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318/v1/traces} logging: level: root: INFO com.lmoraesdev.payment: DEBUG - structured: - format: - console: ecs From 92484f48ef7b7182c5e2fa32df8a27a4b9992946 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Wed, 3 Jun 2026 22:38:24 -0300 Subject: [PATCH 12/38] chore: remove placeholder .gitkeep files and stale devops image --- docs/devops-flow.png | Bin 37193 -> 0 bytes .../payment/adapter/out/persistence/.gitkeep | 0 .../payment/application/port/out/.gitkeep | 0 .../lmoraesdev/payment/domain/model/.gitkeep | 0 src/main/resources/application-prod.yml | 4 ++++ 5 files changed, 4 insertions(+) delete mode 100644 docs/devops-flow.png delete mode 100644 src/main/java/com/lmoraesdev/payment/adapter/out/persistence/.gitkeep delete mode 100644 src/main/java/com/lmoraesdev/payment/application/port/out/.gitkeep delete mode 100644 src/main/java/com/lmoraesdev/payment/domain/model/.gitkeep create mode 100644 src/main/resources/application-prod.yml diff --git a/docs/devops-flow.png b/docs/devops-flow.png deleted file mode 100644 index e1cddec660eec84685394ac6cdd26f36c3075a98..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37193 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 Date: Thu, 4 Jun 2026 19:20:38 -0300 Subject: [PATCH 13/38] feat(application): add CreateCharge contract, command and result --- .../payment/application/port/in/CreateCharge.java | 9 +++++++++ .../application/port/in/CreateChargeCommand.java | 5 +++++ .../application/port/in/CreateChargeResult.java | 12 ++++++++++++ 3 files changed, 26 insertions(+) create mode 100644 src/main/java/com/lmoraesdev/payment/application/port/in/CreateCharge.java create mode 100644 src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeCommand.java create mode 100644 src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeResult.java diff --git a/src/main/java/com/lmoraesdev/payment/application/port/in/CreateCharge.java b/src/main/java/com/lmoraesdev/payment/application/port/in/CreateCharge.java new file mode 100644 index 0000000..ff50aa6 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/application/port/in/CreateCharge.java @@ -0,0 +1,9 @@ +package com.lmoraesdev.payment.application.port.in; + +public interface CreateCharge { + CreateChargeResult create(CreateChargeCommand command); +} + + + + diff --git a/src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeCommand.java b/src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeCommand.java new file mode 100644 index 0000000..0027294 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeCommand.java @@ -0,0 +1,5 @@ +package com.lmoraesdev.payment.application.port.in; + +import java.math.BigDecimal; + +public record CreateChargeCommand(BigDecimal amount) {} diff --git a/src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeResult.java b/src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeResult.java new file mode 100644 index 0000000..add52c0 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeResult.java @@ -0,0 +1,12 @@ +package com.lmoraesdev.payment.application.port.in; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +public record CreateChargeResult( + UUID id, + String status, + BigDecimal amount, + Instant createdAt +) {} From 38573876ba7d19e94df83c5c9e0eabfa3a1c8c73 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Thu, 4 Jun 2026 19:23:55 -0300 Subject: [PATCH 14/38] feat(application): implement CreateChargeService --- .../usecase/CreateChargeService.java | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/main/java/com/lmoraesdev/payment/application/usecase/CreateChargeService.java diff --git a/src/main/java/com/lmoraesdev/payment/application/usecase/CreateChargeService.java b/src/main/java/com/lmoraesdev/payment/application/usecase/CreateChargeService.java new file mode 100644 index 0000000..e650f25 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/application/usecase/CreateChargeService.java @@ -0,0 +1,37 @@ +package com.lmoraesdev.payment.application.usecase; + +import org.springframework.stereotype.Service; + +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.out.ChargeRepository; +import com.lmoraesdev.payment.domain.model.Charge; +import com.lmoraesdev.payment.domain.model.Money; + +@Service +public class CreateChargeService implements CreateCharge { + private final ChargeRepository chargeRepository; + + public CreateChargeService(ChargeRepository chargeRepository){ + this.chargeRepository = chargeRepository; + } + + @Override + public CreateChargeResult create(CreateChargeCommand command){ + + Money amount = new Money(command.amount()); + + Charge charge = Charge.create(amount); + + Charge saved = chargeRepository.save(charge); + + return new CreateChargeResult( + saved.getId(), + saved.getStatus().name(), + saved.getAmount().amount(), + saved.getCreatedAt() + ); + + } +} From d729f023f41e245bf9d57becb1ec7e78549709ee Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Thu, 4 Jun 2026 19:28:50 -0300 Subject: [PATCH 15/38] feat(web): add create charge request and response DTOs --- .../payment/adapter/in/web/CreateChargeRequest.java | 8 ++++++++ .../payment/adapter/in/web/CreateChargeResponse.java | 12 ++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeRequest.java create mode 100644 src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeResponse.java diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeRequest.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeRequest.java new file mode 100644 index 0000000..ee5663b --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeRequest.java @@ -0,0 +1,8 @@ +package com.lmoraesdev.payment.adapter.in.web; + +import java.math.BigDecimal; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +public record CreateChargeRequest(@NotNull @Positive BigDecimal amount) { +} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeResponse.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeResponse.java new file mode 100644 index 0000000..dd2d301 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeResponse.java @@ -0,0 +1,12 @@ +package com.lmoraesdev.payment.adapter.in.web; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +public record CreateChargeResponse( + UUID id, + String status, + BigDecimal amount, + Instant createdAt +) {} From 1b4afd90f25c057eec78664ed6f47b7461c65ec5 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Thu, 4 Jun 2026 19:40:32 -0300 Subject: [PATCH 16/38] feat(web): add POST /charges endpoint --- .../adapter/in/web/ChargeController.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/main/java/com/lmoraesdev/payment/adapter/in/web/ChargeController.java 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 new file mode 100644 index 0000000..83d1f62 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/ChargeController.java @@ -0,0 +1,39 @@ +package com.lmoraesdev.payment.adapter.in.web; + +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +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; +import org.springframework.web.bind.annotation.RestController; + +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 jakarta.validation.Valid; + +@RestController +@RequestMapping("/charges") +public class ChargeController { + private final CreateCharge createCharge; + + public ChargeController(CreateCharge createCharge){ + this.createCharge = createCharge; + } + + @PostMapping + public ResponseEntity create(@Valid @RequestBody CreateChargeRequest request){ + CreateChargeResult result = createCharge.create(new CreateChargeCommand(request.amount())); + + CreateChargeResponse response = new CreateChargeResponse( + result.id(), + result.status(), + result.amount(), + result.createdAt() + ); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } +} From 989e1609a602da7348165fdcd317e96840a64437 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Fri, 5 Jun 2026 15:19:22 -0300 Subject: [PATCH 17/38] chore: remove orphaned .gitkeep placeholders from non-empty packages --- src/main/java/com/lmoraesdev/payment/application/port/in/.gitkeep | 0 src/main/java/com/lmoraesdev/payment/application/usecase/.gitkeep | 0 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/main/java/com/lmoraesdev/payment/application/port/in/.gitkeep delete mode 100644 src/main/java/com/lmoraesdev/payment/application/usecase/.gitkeep diff --git a/src/main/java/com/lmoraesdev/payment/application/port/in/.gitkeep b/src/main/java/com/lmoraesdev/payment/application/port/in/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/lmoraesdev/payment/application/usecase/.gitkeep b/src/main/java/com/lmoraesdev/payment/application/usecase/.gitkeep deleted file mode 100644 index e69de29..0000000 From 984ae63711cf6cffa53a63a0392b83b93e717752 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Fri, 5 Jun 2026 15:19:26 -0300 Subject: [PATCH 18/38] chore(build): add datafaker dependency and JaCoCo coverage reporting - datafaker 2.4.2 (test scope) for realistic test data generation - jacoco-maven-plugin 0.8.13 with prepare-agent + report executions - bind @{argLine} in Surefire so JaCoCo agent is picked up at initialize phase --- pom.xml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/pom.xml b/pom.xml index cf7d602..a0958e9 100644 --- a/pom.xml +++ b/pom.xml @@ -90,6 +90,12 @@ spring-boot-starter-test test + + net.datafaker + datafaker + 2.4.2 + test + org.springframework.boot spring-boot-testcontainers @@ -150,6 +156,8 @@ + + @{argLine} 1.44 @@ -157,6 +165,28 @@ + + + org.jacoco + jacoco-maven-plugin + 0.8.13 + + + prepare-agent + + prepare-agent + + + + report + verify + + report + + + + + From c0d0c9cf8ce1b8c232b98c3d6d6d9e49432d07fb Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Fri, 5 Jun 2026 15:19:33 -0300 Subject: [PATCH 19/38] style: apply Spotless Google Java Format across all sources --- .../adapter/in/web/ChargeController.java | 35 ++++++++----------- .../adapter/in/web/CreateChargeRequest.java | 5 ++- .../adapter/in/web/CreateChargeResponse.java | 7 +--- .../out/persistence/ChargeJpaEntity.java | 26 ++++++-------- .../adapter/out/persistence/ChargeMapper.java | 15 ++++---- .../persistence/ChargeRepositoryAdapter.java | 15 ++++---- .../SpringDataChargeRepository.java | 5 +-- .../application/port/in/CreateCharge.java | 6 +--- .../port/in/CreateChargeResult.java | 7 +--- .../port/out/ChargeRepository.java | 4 +-- .../usecase/CreateChargeService.java | 35 +++++++++---------- .../payment/domain/model/Charge.java | 11 ++---- .../payment/domain/model/ChargeStatus.java | 10 +++--- .../payment/domain/model/Money.java | 8 ++--- 14 files changed, 75 insertions(+), 114 deletions(-) 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 83d1f62..e4f7ba3 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 @@ -1,39 +1,34 @@ package com.lmoraesdev.payment.adapter.in.web; +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 jakarta.validation.Valid; import org.springframework.http.HttpStatus; -import org.springframework.http.HttpStatusCode; 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; import org.springframework.web.bind.annotation.RestController; -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 jakarta.validation.Valid; - @RestController @RequestMapping("/charges") public class ChargeController { - private final CreateCharge createCharge; + private final CreateCharge createCharge; - public ChargeController(CreateCharge createCharge){ - this.createCharge = createCharge; - } + public ChargeController(CreateCharge createCharge) { + this.createCharge = createCharge; + } - @PostMapping - public ResponseEntity create(@Valid @RequestBody CreateChargeRequest request){ + @PostMapping + public ResponseEntity create( + @Valid @RequestBody CreateChargeRequest request) { CreateChargeResult result = createCharge.create(new CreateChargeCommand(request.amount())); - CreateChargeResponse response = new CreateChargeResponse( - result.id(), - result.status(), - result.amount(), - result.createdAt() - ); + CreateChargeResponse response = + new CreateChargeResponse( + result.id(), result.status(), result.amount(), result.createdAt()); return ResponseEntity.status(HttpStatus.CREATED).body(response); - } + } } diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeRequest.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeRequest.java index ee5663b..2861ec5 100644 --- a/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeRequest.java +++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeRequest.java @@ -1,8 +1,7 @@ package com.lmoraesdev.payment.adapter.in.web; -import java.math.BigDecimal; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; +import java.math.BigDecimal; -public record CreateChargeRequest(@NotNull @Positive BigDecimal amount) { -} +public record CreateChargeRequest(@NotNull @Positive BigDecimal amount) {} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeResponse.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeResponse.java index dd2d301..15c1d85 100644 --- a/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeResponse.java +++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeResponse.java @@ -4,9 +4,4 @@ import java.time.Instant; import java.util.UUID; -public record CreateChargeResponse( - UUID id, - String status, - BigDecimal amount, - Instant createdAt -) {} +public record CreateChargeResponse(UUID id, String status, BigDecimal amount, Instant createdAt) {} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeJpaEntity.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeJpaEntity.java index 6398748..ed05976 100644 --- a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeJpaEntity.java +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeJpaEntity.java @@ -1,18 +1,15 @@ package com.lmoraesdev.payment.adapter.out.persistence; +import com.lmoraesdev.payment.domain.model.ChargeStatus; +import jakarta.persistence.*; import java.math.BigDecimal; import java.time.Instant; import java.util.UUID; -import com.lmoraesdev.payment.domain.model.ChargeStatus; - -import jakarta.persistence.*; - @Entity @Table(name = "charges") public class ChargeJpaEntity { - @Id - private UUID id; + @Id private UUID id; @Column(nullable = false) private BigDecimal amount; @@ -24,21 +21,21 @@ public class ChargeJpaEntity { @Column(nullable = false) private Instant createdAt; - protected ChargeJpaEntity(){} + protected ChargeJpaEntity() {} - public UUID getId(){ + public UUID getId() { return id; } - public void setId(UUID id){ + public void setId(UUID id) { this.id = id; } - public BigDecimal getAmount(){ + public BigDecimal getAmount() { return amount; } - public void setAmount(BigDecimal amount){ + public void setAmount(BigDecimal amount) { this.amount = amount; } @@ -46,16 +43,15 @@ public ChargeStatus getStatus() { return status; } - public void setStatus(ChargeStatus status){ + public void setStatus(ChargeStatus status) { this.status = status; } - public Instant getCreatedAt(){ + public Instant getCreatedAt() { return createdAt; } - public void setCreatedAt(Instant createdAt){ + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } - } diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeMapper.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeMapper.java index 5ae2985..51d0229 100644 --- a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeMapper.java +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeMapper.java @@ -4,9 +4,9 @@ import com.lmoraesdev.payment.domain.model.Money; final class ChargeMapper { -private ChargeMapper(){} + private ChargeMapper() {} - static ChargeJpaEntity toEntity(Charge charge){ + static ChargeJpaEntity toEntity(Charge charge) { ChargeJpaEntity entity = new ChargeJpaEntity(); entity.setId(charge.getId()); @@ -17,13 +17,12 @@ static ChargeJpaEntity toEntity(Charge charge){ return entity; } - static Charge toDomain(ChargeJpaEntity entity){ + static Charge toDomain(ChargeJpaEntity entity) { return Charge.restore( - entity.getId(), - new Money(entity.getAmount()), - entity.getStatus(), - entity.getCreatedAt() - ); + entity.getId(), + new Money(entity.getAmount()), + entity.getStatus(), + entity.getCreatedAt()); } } diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryAdapter.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryAdapter.java index 01303de..3446f69 100644 --- a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryAdapter.java +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryAdapter.java @@ -2,21 +2,20 @@ import com.lmoraesdev.payment.application.port.out.ChargeRepository; import com.lmoraesdev.payment.domain.model.Charge; -import org.springframework.stereotype.Component; - import java.util.Optional; import java.util.UUID; +import org.springframework.stereotype.Component; @Component -public class ChargeRepositoryAdapter implements ChargeRepository{ +public class ChargeRepositoryAdapter implements ChargeRepository { private final SpringDataChargeRepository repository; - public ChargeRepositoryAdapter(SpringDataChargeRepository repository){ + public ChargeRepositoryAdapter(SpringDataChargeRepository repository) { this.repository = repository; } @Override - public Charge save(Charge charge){ + public Charge save(Charge charge) { ChargeJpaEntity entity = ChargeMapper.toEntity(charge); ChargeJpaEntity saved = repository.save(entity); @@ -25,9 +24,7 @@ public Charge save(Charge charge){ } @Override - public Optional findById(UUID id){ - return repository.findById(id) - .map(ChargeMapper::toDomain); + public Optional findById(UUID id) { + return repository.findById(id).map(ChargeMapper::toDomain); } - } diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/SpringDataChargeRepository.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/SpringDataChargeRepository.java index 39127da..1ac5203 100644 --- a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/SpringDataChargeRepository.java +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/SpringDataChargeRepository.java @@ -1,9 +1,6 @@ package com.lmoraesdev.payment.adapter.out.persistence; import java.util.UUID; - import org.springframework.data.jpa.repository.JpaRepository; -public interface SpringDataChargeRepository - extends JpaRepository{ -} +public interface SpringDataChargeRepository extends JpaRepository {} diff --git a/src/main/java/com/lmoraesdev/payment/application/port/in/CreateCharge.java b/src/main/java/com/lmoraesdev/payment/application/port/in/CreateCharge.java index ff50aa6..50b81ec 100644 --- a/src/main/java/com/lmoraesdev/payment/application/port/in/CreateCharge.java +++ b/src/main/java/com/lmoraesdev/payment/application/port/in/CreateCharge.java @@ -1,9 +1,5 @@ package com.lmoraesdev.payment.application.port.in; public interface CreateCharge { - CreateChargeResult create(CreateChargeCommand command); + CreateChargeResult create(CreateChargeCommand command); } - - - - diff --git a/src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeResult.java b/src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeResult.java index add52c0..748d461 100644 --- a/src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeResult.java +++ b/src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeResult.java @@ -4,9 +4,4 @@ import java.time.Instant; import java.util.UUID; -public record CreateChargeResult( - UUID id, - String status, - BigDecimal amount, - Instant createdAt -) {} +public record CreateChargeResult(UUID id, String status, BigDecimal amount, Instant createdAt) {} diff --git a/src/main/java/com/lmoraesdev/payment/application/port/out/ChargeRepository.java b/src/main/java/com/lmoraesdev/payment/application/port/out/ChargeRepository.java index b03f95c..1597f53 100644 --- a/src/main/java/com/lmoraesdev/payment/application/port/out/ChargeRepository.java +++ b/src/main/java/com/lmoraesdev/payment/application/port/out/ChargeRepository.java @@ -1,11 +1,11 @@ package com.lmoraesdev.payment.application.port.out; +import com.lmoraesdev.payment.domain.model.Charge; import java.util.Optional; import java.util.UUID; -import com.lmoraesdev.payment.domain.model.Charge; - public interface ChargeRepository { Charge save(Charge charge); + Optional findById(UUID id); } diff --git a/src/main/java/com/lmoraesdev/payment/application/usecase/CreateChargeService.java b/src/main/java/com/lmoraesdev/payment/application/usecase/CreateChargeService.java index e650f25..e9e1f6d 100644 --- a/src/main/java/com/lmoraesdev/payment/application/usecase/CreateChargeService.java +++ b/src/main/java/com/lmoraesdev/payment/application/usecase/CreateChargeService.java @@ -1,37 +1,34 @@ package com.lmoraesdev.payment.application.usecase; -import org.springframework.stereotype.Service; - 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.out.ChargeRepository; import com.lmoraesdev.payment.domain.model.Charge; import com.lmoraesdev.payment.domain.model.Money; +import org.springframework.stereotype.Service; @Service public class CreateChargeService implements CreateCharge { - private final ChargeRepository chargeRepository; - - public CreateChargeService(ChargeRepository chargeRepository){ - this.chargeRepository = chargeRepository; - } + private final ChargeRepository chargeRepository; - @Override - public CreateChargeResult create(CreateChargeCommand command){ + public CreateChargeService(ChargeRepository chargeRepository) { + this.chargeRepository = chargeRepository; + } - Money amount = new Money(command.amount()); + @Override + public CreateChargeResult create(CreateChargeCommand command) { - Charge charge = Charge.create(amount); + Money amount = new Money(command.amount()); - Charge saved = chargeRepository.save(charge); + Charge charge = Charge.create(amount); - return new CreateChargeResult( - saved.getId(), - saved.getStatus().name(), - saved.getAmount().amount(), - saved.getCreatedAt() - ); + Charge saved = chargeRepository.save(charge); - } + return new CreateChargeResult( + saved.getId(), + saved.getStatus().name(), + saved.getAmount().amount(), + saved.getCreatedAt()); + } } diff --git a/src/main/java/com/lmoraesdev/payment/domain/model/Charge.java b/src/main/java/com/lmoraesdev/payment/domain/model/Charge.java index eae6362..b106d89 100644 --- a/src/main/java/com/lmoraesdev/payment/domain/model/Charge.java +++ b/src/main/java/com/lmoraesdev/payment/domain/model/Charge.java @@ -10,22 +10,17 @@ public class Charge { private ChargeStatus status; private final Instant createdAt; - private Charge(UUID id, Money amount, ChargeStatus status, Instant createdAt){ + private Charge(UUID id, Money amount, ChargeStatus status, Instant createdAt) { this.id = id; this.amount = amount; this.status = status; this.createdAt = createdAt; } - public static Charge create(Money amount){ + public static Charge create(Money amount) { Objects.requireNonNull(amount, "O montante (Money) é obrigatório"); - return new Charge( - UUID.randomUUID(), - amount, - ChargeStatus.ACTIVE, - Instant.now() - ); + return new Charge(UUID.randomUUID(), amount, ChargeStatus.ACTIVE, Instant.now()); } public static Charge restore(UUID id, Money amount, ChargeStatus status, Instant createdAt) { diff --git a/src/main/java/com/lmoraesdev/payment/domain/model/ChargeStatus.java b/src/main/java/com/lmoraesdev/payment/domain/model/ChargeStatus.java index cec6d18..89790e7 100644 --- a/src/main/java/com/lmoraesdev/payment/domain/model/ChargeStatus.java +++ b/src/main/java/com/lmoraesdev/payment/domain/model/ChargeStatus.java @@ -1,9 +1,9 @@ package com.lmoraesdev.payment.domain.model; public enum ChargeStatus { - PENDING, - ACTIVE, - PAID, - EXPIRED, - CANCELLED + PENDING, + ACTIVE, + PAID, + EXPIRED, + CANCELLED } diff --git a/src/main/java/com/lmoraesdev/payment/domain/model/Money.java b/src/main/java/com/lmoraesdev/payment/domain/model/Money.java index 84dadc3..2e5d07c 100644 --- a/src/main/java/com/lmoraesdev/payment/domain/model/Money.java +++ b/src/main/java/com/lmoraesdev/payment/domain/model/Money.java @@ -4,12 +4,12 @@ import java.util.Objects; public record Money(BigDecimal amount) { - public Money{ + public Money { Objects.requireNonNull(amount, "O valor (amount) não pode ser nulo"); - if(amount.compareTo(BigDecimal.ZERO) <= 0) { - throw new IllegalArgumentException("O valor monetário deve ser estritamente maior que zero"); + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException( + "O valor monetário deve ser estritamente maior que zero"); } } } - From cd7b15491f89b344cbc35a61f6d5fa5429eb6538 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Fri, 5 Jun 2026 15:19:38 -0300 Subject: [PATCH 20/38] test(support): add integration test base class, ChargeTestData builder and testing guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AbstractIntegrationTest: @SpringBootTest + @Testcontainers + postgres:18-alpine base - ChargeTestData: fluent builder for Charge domain object in tests - TESTING.md: documents the Node/TS→Java test pattern migration - Remove PaymentApiApplicationIT (replaced by focused slice tests) --- TESTING.md | 181 ++++++++++++++++++ .../AbstractIntegrationTest.java} | 10 +- .../payment/testdata/ChargeTestData.java | 38 ++++ 3 files changed, 222 insertions(+), 7 deletions(-) create mode 100644 TESTING.md rename src/test/java/com/lmoraesdev/payment/{PaymentApiApplicationIT.java => support/AbstractIntegrationTest.java} (80%) create mode 100644 src/test/java/com/lmoraesdev/payment/testdata/ChargeTestData.java diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..7df1dcb --- /dev/null +++ b/TESTING.md @@ -0,0 +1,181 @@ +# Testing Standard (Java) — portado do Test Table Pattern + +Este é o padrão de testes do projeto, adaptado do meu padrão antigo de Node/TS +para os idiomas nativos do Java. Regra de ouro: **manter os princípios, NÃO +recriar a camada de helpers** — JUnit 5 + AssertJ + Mockito já entregam isso. + +## Princípios que vêm do padrão antigo (valem 100%) + +- Table-driven: casos de teste como dados, não como `it()` duplicados. +- Builders pra entidades de domínio; fixtures pra payloads complexos. +- Separar claramente "Success" e "Validation errors". +- Mapear TODAS as branches antes de escrever (if/else/try/for/switch e combinações). +- Testar o comportamento REAL do código, não o assumido. +- Testar resiliência em observer/orchestrator (falha parcial não derruba o fluxo). +- Refatorar use case complexo em métodos privados de responsabilidade única. +- Cobertura: use cases críticos ~100% (sucesso + erros esperados). + +## Mapa Node/TS → Java (o que muda) + +| Padrão antigo (vitest+sinon) | Equivalente NATIVO em Java | Veredito | +| ----------------------------------- | --------------------------------------------------- | -------- | +| `runTests(testCases, cb)` | `@ParameterizedTest` + `@MethodSource` | usar nativo, não recriar | +| `testCases: TestCase[]` | `Stream` (record) via `@MethodSource` | porta fiel e idiomática | +| `it.skip` (testType) | `@Disabled("motivo")` | nativo | +| `it.only` | rodar 1 teste (IDE / `-Dtest=Classe#metodo`) | não existe data-driven; ok | +| `getStub(sandbox, X.prototype, m)` | `@Mock` + `when(mock.m()).thenReturn/thenThrow` | usar nativo | +| `executeStubAssertions(callMatchs)` | `verify(mock).m(args)`, `times(n)`, `ArgumentCaptor`| usar nativo | +| `getError(cb)` | `assertThatThrownBy(() -> ...)` / `catchThrowable` | usar nativo | +| DI container `container.get(...)` | unit: `new Service(mock)`; integração: `@SpringBootTest` | ver nota | +| builders `.getData()` / `.build()` | builder retorna domínio; persistir = via repository | manter | +| fixtures `*-fixture.ts` | `*Fixtures` (factory estática) ou builder | manter | +| cobertura (vitest) | JaCoCo (`jacoco-maven-plugin`) | trocar ferramenta | + +## O que NÃO portar (e por quê) + +- **O DSL declarativo de stubs** (`input.stubs.x.resolves` + `output.stubs.callMatchs`). + Em Java, parametrize os INPUTS e os EXPECTED; o setup de stub e o `verify` ficam + imperativos no corpo do teste, com Mockito. Tentar codar stub-como-dado vira um + mini-framework que briga com a linguagem. +- **Custom runner** (`runTests`). `@ParameterizedTest` é o runner. +- **DI container no unit test.** Em Java, teste UNITÁRIO instancia direto + (`new CreateChargeService(mockRepo)`) — mais rápido e isolado, sem subir Spring. + Container só em teste de INTEGRAÇÃO (`@SpringBootTest`/`@DataJpaTest`). + +## Onde o Java é ATÉ melhor + +Seu padrão antigo dizia "stubar classes concretas, não interfaces" (limitação do +sinon). No Java/Mockito você mocka a **interface** (a porta `ChargeRepository`) +diretamente — que é a inversão de dependência feita certo. Mais limpo, e alinhado +com a clean arch. + +## Stack + +JUnit 5 (Jupiter) · AssertJ · Mockito · Testcontainers · JaCoCo (cobertura). +Tudo via `spring-boot-starter-test`, menos o JaCoCo (plugin no pom). + +--- + +## Exemplo 1 — Table-driven (o "testCases" do jeito Java) + +```java +@DisplayName("Money") +class MoneyTest { + + record Case(String name, String amount, boolean valid) { + @Override public String toString() { return name; } + } + + static Stream cases() { + return Stream.of( + new Case("aceita centavo mínimo", "0.01", true), + new Case("aceita valor comum", "10.50", true), + new Case("rejeita negativo", "-1.00", false), + new Case("rejeita zero", "0.00", false) + ); + } + + @ParameterizedTest + @MethodSource("cases") + void validates(Case c) { + if (c.valid()) { + assertThat(new Money(new BigDecimal(c.amount())).amount()) + .isEqualByComparingTo(c.amount()); + } else { + assertThatThrownBy(() -> new Money(new BigDecimal(c.amount()))) + .isInstanceOf(IllegalArgumentException.class); + } + } +} +``` + +## Exemplo 2 — Test Data Builder (BASE + sobrescreve só o que muda) + +```java +public final class ChargeTestData { + private Money amount = new Money(new BigDecimal("10.50")); + private ChargeStatus status = ChargeStatus.ACTIVE; + + public static ChargeTestData aCharge() { return new ChargeTestData(); } + public static Money money(String v) { return new Money(new BigDecimal(v)); } + + public ChargeTestData withAmount(String v) { + this.amount = new Money(new BigDecimal(v)); + return this; + } + public ChargeTestData withStatus(ChargeStatus s) { + this.status = s; + return this; + } + public Charge build() { + return Charge.restore(UUID.randomUUID(), amount, status, Instant.now()); + } +} +// uso: ChargeTestData.aCharge().withAmount("99.90").build(); +``` + +## Exemplo 3 — Use case com mocks (sucesso + verificação) + +```java +@ExtendWith(MockitoExtension.class) +class CreateChargeServiceTest { + + @Mock ChargeRepository repository; + @InjectMocks CreateChargeService service; + + @Nested @DisplayName("Success") + class Success { + @Test + void cria_e_persiste_a_cobranca() { + when(repository.save(any())).thenAnswer(i -> i.getArgument(0)); + + var result = service.create(new CreateChargeCommand(new BigDecimal("10.50"))); + + var captor = ArgumentCaptor.forClass(Charge.class); + verify(repository).save(captor.capture()); + assertThat(captor.getValue().getStatus()).isEqualTo(ChargeStatus.ACTIVE); + assertThat(result.amount()).isEqualByComparingTo("10.50"); + } + } + + @Nested @DisplayName("Validation errors") + class ValidationErrors { + @Test + void nao_persiste_quando_valor_invalido() { + assertThatThrownBy(() -> + service.create(new CreateChargeCommand(new BigDecimal("-1")))) + .isInstanceOf(IllegalArgumentException.class); + + verify(repository, never()).save(any()); + } + } +} +``` + +## Exemplo 4 — Resiliência de orchestrator (quando existir) + +```java +when(subscriberA.notify(any())).thenReturn(...); +doThrow(new RuntimeException("falhou")).when(subscriberB).notify(any()); +// act +service.execute(event); +// assert: B falhou MAS C ainda foi chamado +verify(subscriberC).notify(any()); +``` + +## Convenções + +- `*Test` → unitário (surefire, fase `test`); `*IT` → integração (failsafe, fase `verify`). +- `@Nested` pra agrupar Success / ValidationErrors. `@DisplayName` em tudo. +- Dados de borda = fixos e determinísticos. `datafaker` só pra preenchimento "qualquer válido". +- BigDecimal: sempre `isEqualByComparingTo`, nunca `isEqualTo`. + +## Checklist (adaptado do antigo) + +- [ ] Todas as branches cobertas? (mapeie antes de escrever) +- [ ] Sucesso e erro testados? Early returns cobertos? +- [ ] Mockou a INTERFACE (porta), não a implementação concreta? +- [ ] Unit test sem Spring (new + mock); integração com @SpringBootTest/Testcontainers? +- [ ] Verificou chamadas dos mocks (verify/ArgumentCaptor)? +- [ ] Teste reflete o comportamento REAL (não o assumido)? +- [ ] Sem helper de infra caseiro reinventando JUnit/Mockito/AssertJ? diff --git a/src/test/java/com/lmoraesdev/payment/PaymentApiApplicationIT.java b/src/test/java/com/lmoraesdev/payment/support/AbstractIntegrationTest.java similarity index 80% rename from src/test/java/com/lmoraesdev/payment/PaymentApiApplicationIT.java rename to src/test/java/com/lmoraesdev/payment/support/AbstractIntegrationTest.java index 5c30881..4f6c2a8 100644 --- a/src/test/java/com/lmoraesdev/payment/PaymentApiApplicationIT.java +++ b/src/test/java/com/lmoraesdev/payment/support/AbstractIntegrationTest.java @@ -1,6 +1,5 @@ -package com.lmoraesdev.payment; +package com.lmoraesdev.payment.support; -import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.test.context.ActiveProfiles; @@ -9,13 +8,10 @@ import org.testcontainers.junit.jupiter.Testcontainers; @SpringBootTest -@Testcontainers @ActiveProfiles("test") -class PaymentApiApplicationIT { +@Testcontainers +public abstract class AbstractIntegrationTest { @Container @ServiceConnection static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:18-alpine"); - - @Test - void contextLoads() {} } diff --git a/src/test/java/com/lmoraesdev/payment/testdata/ChargeTestData.java b/src/test/java/com/lmoraesdev/payment/testdata/ChargeTestData.java new file mode 100644 index 0000000..7b11682 --- /dev/null +++ b/src/test/java/com/lmoraesdev/payment/testdata/ChargeTestData.java @@ -0,0 +1,38 @@ +package com.lmoraesdev.payment.testdata; + +import com.lmoraesdev.payment.domain.model.Charge; +import com.lmoraesdev.payment.domain.model.ChargeStatus; +import com.lmoraesdev.payment.domain.model.Money; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +public final class ChargeTestData { + + private Money amount = new Money(new BigDecimal("10.50")); + private ChargeStatus status = ChargeStatus.ACTIVE; + + private ChargeTestData() {} + + public static ChargeTestData aCharge() { + return new ChargeTestData(); + } + + public static Money money(String value) { + return new Money(new BigDecimal(value)); + } + + public ChargeTestData withAmount(String value) { + this.amount = new Money(new BigDecimal(value)); + return this; + } + + public ChargeTestData withStatus(ChargeStatus s) { + this.status = s; + return this; + } + + public Charge build() { + return Charge.restore(UUID.randomUUID(), amount, status, Instant.now()); + } +} From 506a603847389f9b075d801fa49ea2d6d92991dd Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Fri, 5 Jun 2026 16:06:51 -0300 Subject: [PATCH 21/38] feat(application): add business-event logging to CreateChargeService Log charge_created in INFO after successful save, capturing the business event without leaking infrastructure details. --- .../payment/application/usecase/CreateChargeService.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/com/lmoraesdev/payment/application/usecase/CreateChargeService.java b/src/main/java/com/lmoraesdev/payment/application/usecase/CreateChargeService.java index e9e1f6d..c309a43 100644 --- a/src/main/java/com/lmoraesdev/payment/application/usecase/CreateChargeService.java +++ b/src/main/java/com/lmoraesdev/payment/application/usecase/CreateChargeService.java @@ -4,6 +4,7 @@ import com.lmoraesdev.payment.application.port.in.CreateChargeCommand; import com.lmoraesdev.payment.application.port.in.CreateChargeResult; import com.lmoraesdev.payment.application.port.out.ChargeRepository; +import com.lmoraesdev.payment.config.logging.Logger5w1hBuilder; import com.lmoraesdev.payment.domain.model.Charge; import com.lmoraesdev.payment.domain.model.Money; import org.springframework.stereotype.Service; @@ -25,6 +26,14 @@ public CreateChargeResult create(CreateChargeCommand command) { Charge saved = chargeRepository.save(charge); + Logger5w1hBuilder.create(CreateChargeService.class) + .where("CreateChargeService") + .what("charge_created") + .why("charge creation requested") + .who("system") + .how("createCharge") + .info(); + return new CreateChargeResult( saved.getId(), saved.getStatus().name(), From b6f125fdedb184fee75b32b152f2b1edadf46c15 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Fri, 5 Jun 2026 16:07:03 -0300 Subject: [PATCH 22/38] chore(web): enable RFC 9457 problem details --- src/main/resources/application.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a1b4698..0f96c98 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -20,6 +20,9 @@ spring: producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.apache.kafka.common.serialization.StringSerializer + mvc: + problemdetails: + enabled: true data: redis: repositories: From aed535afef394c9eca189a5d973f257751151444 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Fri, 5 Jun 2026 16:09:02 -0300 Subject: [PATCH 23/38] fix(web): return problem details with correct status per error type (400/422/500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MethodArgumentNotValidException → 400 with per-field errors map and traceId - DomainException → 422 with message and traceId - Exception (unexpected) → 500 logged in ERROR; only case where 4xx are never logged - @Order(HIGHEST_PRECEDENCE) ensures this handler wins over Spring Boot's ProblemDetailsExceptionHandler --- .../in/web/GlobalExceptionHandler.java | 54 +++++++++++++++++-- 1 file changed, 49 insertions(+), 5 deletions(-) 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 65cee46..df614e1 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,18 +1,51 @@ package com.lmoraesdev.payment.adapter.in.web; import com.lmoraesdev.payment.config.logging.Logger5w1hBuilder; -import java.time.LocalDateTime; +import com.lmoraesdev.payment.domain.exception.DomainException; +import java.util.HashMap; import java.util.Map; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; +import org.springframework.http.ProblemDetail; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +@Order(Ordered.HIGHEST_PRECEDENCE) @RestControllerAdvice public class GlobalExceptionHandler { + // 400 — validação. Erro esperado do cliente: NÃO loga. + @ExceptionHandler(MethodArgumentNotValidException.class) + public ProblemDetail handleValidation(MethodArgumentNotValidException ex) { + ProblemDetail problem = + ProblemDetail.forStatusAndDetail( + HttpStatus.BAD_REQUEST, "Um ou mais campos são inválidos"); + problem.setTitle("Validation failed"); + Map errors = new HashMap<>(); + ex.getBindingResult() + .getFieldErrors() + .forEach(e -> errors.put(e.getField(), e.getDefaultMessage())); + problem.setProperty("errors", errors); + addTraceId(problem); + return problem; + } + + // 422 — regra de negócio. Também esperado: NÃO loga. + @ExceptionHandler(DomainException.class) + public ProblemDetail handleDomain(DomainException ex) { + ProblemDetail problem = + ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage()); + problem.setTitle("Business rule violation"); + addTraceId(problem); + return problem; + } + + // 500 — inesperado. AQUI sim loga, em ERROR, com a stack. @ExceptionHandler(Exception.class) - public ResponseEntity> handleException(Exception ex) { + public ProblemDetail handleUnexpected(Exception ex) { Logger5w1hBuilder.create(GlobalExceptionHandler.class) .where("GlobalExceptionHandler") .what("unexpected_error") @@ -21,7 +54,18 @@ public ResponseEntity> handleException(Exception ex) { .how("exception handling") .error(ex); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("message", "Unexpected error", "timestamp", LocalDateTime.now())); + ProblemDetail problem = + ProblemDetail.forStatusAndDetail( + HttpStatus.INTERNAL_SERVER_ERROR, "Erro interno inesperado"); + problem.setTitle("Internal error"); + addTraceId(problem); + return problem; + } + + private void addTraceId(ProblemDetail problem) { + String traceId = MDC.get("traceId"); + if (traceId != null) { + problem.setProperty("traceId", traceId); + } } } From b62e41149894a73a84f74fc147db93425c451ed9 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Fri, 5 Jun 2026 16:10:16 -0300 Subject: [PATCH 24/38] test(support): add shared Testcontainers config and integration base - TestcontainersConfiguration: shared postgres:17-alpine container with @ServiceConnection - AbstractIntegrationTest: @DataJpaTest slice + @AutoConfigureTestDatabase(replace=NONE) so persistence tests use real Postgres instead of H2 --- .../support/AbstractIntegrationTest.java | 19 +++++++------------ .../support/TestcontainersConfiguration.java | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 12 deletions(-) create mode 100644 src/test/java/com/lmoraesdev/payment/support/TestcontainersConfiguration.java diff --git a/src/test/java/com/lmoraesdev/payment/support/AbstractIntegrationTest.java b/src/test/java/com/lmoraesdev/payment/support/AbstractIntegrationTest.java index 4f6c2a8..24700db 100644 --- a/src/test/java/com/lmoraesdev/payment/support/AbstractIntegrationTest.java +++ b/src/test/java/com/lmoraesdev/payment/support/AbstractIntegrationTest.java @@ -1,17 +1,12 @@ package com.lmoraesdev.payment.support; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; -import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -@SpringBootTest +@DataJpaTest +@Import(TestcontainersConfiguration.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @ActiveProfiles("test") -@Testcontainers -public abstract class AbstractIntegrationTest { - - @Container @ServiceConnection - static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:18-alpine"); -} +public abstract class AbstractIntegrationTest {} diff --git a/src/test/java/com/lmoraesdev/payment/support/TestcontainersConfiguration.java b/src/test/java/com/lmoraesdev/payment/support/TestcontainersConfiguration.java new file mode 100644 index 0000000..b8dc856 --- /dev/null +++ b/src/test/java/com/lmoraesdev/payment/support/TestcontainersConfiguration.java @@ -0,0 +1,16 @@ +package com.lmoraesdev.payment.support; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.testcontainers.containers.PostgreSQLContainer; + +@TestConfiguration(proxyBeanMethods = false) +public class TestcontainersConfiguration { + + @Bean + @ServiceConnection + PostgreSQLContainer postgresContainer() { + return new PostgreSQLContainer<>("postgres:17-alpine"); + } +} From 1224b42abf625a5ae6f9bd37ce3ca3f823e6b065 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Fri, 5 Jun 2026 16:11:37 -0300 Subject: [PATCH 25/38] test(domain): cover Money validation and Charge identity --- .../payment/domain/model/ChargeTest.java | 63 +++++++++++++++++++ .../payment/domain/model/MoneyTest.java | 49 +++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 src/test/java/com/lmoraesdev/payment/domain/model/ChargeTest.java create mode 100644 src/test/java/com/lmoraesdev/payment/domain/model/MoneyTest.java diff --git a/src/test/java/com/lmoraesdev/payment/domain/model/ChargeTest.java b/src/test/java/com/lmoraesdev/payment/domain/model/ChargeTest.java new file mode 100644 index 0000000..fdc9e53 --- /dev/null +++ b/src/test/java/com/lmoraesdev/payment/domain/model/ChargeTest.java @@ -0,0 +1,63 @@ +package com.lmoraesdev.payment.domain.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Charge") +class ChargeTest { + + private final Money amount = new Money(new BigDecimal("10.00")); + + @Test + @DisplayName("create gera id único e inicia ACTIVE") + void createGeneratesUniqueIdAndStartsActive() { + Charge a = Charge.create(amount); + Charge b = Charge.create(amount); + + assertThat(a.getStatus()).isEqualTo(ChargeStatus.ACTIVE); + assertThat(a.getId()).isNotNull(); + assertThat(a.getCreatedAt()).isNotNull(); + assertThat(a.getId()).isNotEqualTo(b.getId()); + } + + @Test + @DisplayName("create rejeita amount nulo") + void createRejectsNullAmount() { + assertThatThrownBy(() -> Charge.create(null)).isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("restore reconstitui sem gerar novo id ou data") + void restorePreservesIdAndTimestamp() { + UUID id = UUID.randomUUID(); + Instant createdAt = Instant.parse("2025-01-01T00:00:00Z"); + + Charge charge = Charge.restore(id, amount, ChargeStatus.PAID, createdAt); + + assertThat(charge.getId()).isEqualTo(id); + assertThat(charge.getStatus()).isEqualTo(ChargeStatus.PAID); + assertThat(charge.getAmount()).isEqualTo(amount); + assertThat(charge.getCreatedAt()).isEqualTo(createdAt); + } + + @Test + @DisplayName("equals e hashCode baseados somente no id") + void equalityIsIdBased() { + UUID id = UUID.randomUUID(); + Charge a = Charge.restore(id, amount, ChargeStatus.ACTIVE, Instant.now()); + Charge b = + Charge.restore( + id, new Money(new BigDecimal("99.00")), ChargeStatus.PAID, Instant.now()); + Charge c = Charge.restore(UUID.randomUUID(), amount, ChargeStatus.ACTIVE, Instant.now()); + + assertThat(a).isEqualTo(b); + assertThat(a).isNotEqualTo(c); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } +} diff --git a/src/test/java/com/lmoraesdev/payment/domain/model/MoneyTest.java b/src/test/java/com/lmoraesdev/payment/domain/model/MoneyTest.java new file mode 100644 index 0000000..9d5ea3b --- /dev/null +++ b/src/test/java/com/lmoraesdev/payment/domain/model/MoneyTest.java @@ -0,0 +1,49 @@ +package com.lmoraesdev.payment.domain.model; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +@DisplayName("Money") +class MoneyTest { + + record Case(String name, String amount, boolean valid) { + @Override + public String toString() { + return name; + } + } + + static Stream cases() { + return Stream.of( + new Case("aceita centavo mínimo", "0.01", true), + new Case("aceita valor comum", "10.50", true), + new Case("aceita valor alto", "9999999.99", true), + new Case("rejeita zero", "0.00", false), + new Case("rejeita negativo", "-1.00", false), + new Case("rejeita negativo pequeno", "-0.01", false)); + } + + @ParameterizedTest + @MethodSource("cases") + void validates(Case c) { + BigDecimal value = new BigDecimal(c.amount()); + if (c.valid()) { + assertThatNoException().isThrownBy(() -> new Money(value)); + } else { + assertThatThrownBy(() -> new Money(value)).isInstanceOf(IllegalArgumentException.class); + } + } + + @Test + @DisplayName("rejeita amount nulo") + void rejectsNull() { + assertThatThrownBy(() -> new Money(null)).isInstanceOf(NullPointerException.class); + } +} From 2ff3cf9af51ea593139568b776c35a7b39128e39 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Fri, 5 Jun 2026 16:11:41 -0300 Subject: [PATCH 26/38] test(application): unit test CreateChargeService with mocked repository --- .../usecase/CreateChargeServiceTest.java | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/test/java/com/lmoraesdev/payment/application/usecase/CreateChargeServiceTest.java diff --git a/src/test/java/com/lmoraesdev/payment/application/usecase/CreateChargeServiceTest.java b/src/test/java/com/lmoraesdev/payment/application/usecase/CreateChargeServiceTest.java new file mode 100644 index 0000000..62347c0 --- /dev/null +++ b/src/test/java/com/lmoraesdev/payment/application/usecase/CreateChargeServiceTest.java @@ -0,0 +1,75 @@ +package com.lmoraesdev.payment.application.usecase; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.lmoraesdev.payment.application.port.in.CreateChargeCommand; +import com.lmoraesdev.payment.application.port.in.CreateChargeResult; +import com.lmoraesdev.payment.application.port.out.ChargeRepository; +import com.lmoraesdev.payment.domain.model.ChargeStatus; +import java.math.BigDecimal; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@DisplayName("CreateChargeService") +@ExtendWith(MockitoExtension.class) +class CreateChargeServiceTest { + + @Mock ChargeRepository chargeRepository; + + @InjectMocks CreateChargeService service; + + record Case(String name, String amount) { + @Override + public String toString() { + return name; + } + } + + static Stream validAmounts() { + return Stream.of( + new Case("centavo mínimo", "0.01"), + new Case("valor comum", "100.00"), + new Case("valor alto", "50000.00")); + } + + @ParameterizedTest + @MethodSource("validAmounts") + @DisplayName("cria cobrança e retorna result com status ACTIVE") + void createsChargeSuccessfully(Case c) { + when(chargeRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + CreateChargeResult result = + service.create(new CreateChargeCommand(new BigDecimal(c.amount()))); + + assertThat(result.id()).isNotNull(); + assertThat(result.status()).isEqualTo(ChargeStatus.ACTIVE.name()); + assertThat(result.amount()).isEqualByComparingTo(c.amount()); + assertThat(result.createdAt()).isNotNull(); + verify(chargeRepository).save(any()); + } + + @Test + @DisplayName("propaga exceção para amount zero ou negativo") + void propagatesExceptionForInvalidAmount() { + assertThatThrownBy(() -> service.create(new CreateChargeCommand(BigDecimal.ZERO))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("propaga exceção para amount nulo") + void propagatesExceptionForNullAmount() { + assertThatThrownBy(() -> service.create(new CreateChargeCommand(null))) + .isInstanceOf(NullPointerException.class); + } +} From 2358f4f662620027f775a600d3cae43a51037d4f Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Fri, 5 Jun 2026 16:11:44 -0300 Subject: [PATCH 27/38] test(persistence): add ChargeRepositoryIT round-trip with Testcontainers --- .../out/persistence/ChargeRepositoryIT.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/test/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryIT.java diff --git a/src/test/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryIT.java b/src/test/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryIT.java new file mode 100644 index 0000000..48343be --- /dev/null +++ b/src/test/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryIT.java @@ -0,0 +1,44 @@ +package com.lmoraesdev.payment.adapter.out.persistence; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.lmoraesdev.payment.application.port.out.ChargeRepository; +import com.lmoraesdev.payment.domain.model.Charge; +import com.lmoraesdev.payment.support.AbstractIntegrationTest; +import com.lmoraesdev.payment.testdata.ChargeTestData; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; + +@Import(ChargeRepositoryAdapter.class) +@DisplayName("ChargeRepositoryIT") +class ChargeRepositoryIT extends AbstractIntegrationTest { + + @Autowired ChargeRepository chargeRepository; + + @Test + @DisplayName("save e findById preservam todos os campos") + void roundTripPreservesAllFields() { + Charge charge = ChargeTestData.aCharge().build(); + + Charge saved = chargeRepository.save(charge); + Optional found = chargeRepository.findById(saved.getId()); + + assertThat(found).isPresent(); + Charge result = found.get(); + assertThat(result.getId()).isEqualTo(charge.getId()); + assertThat(result.getAmount().amount()).isEqualByComparingTo(charge.getAmount().amount()); + assertThat(result.getStatus()).isEqualTo(charge.getStatus()); + assertThat(result.getCreatedAt()).isEqualTo(charge.getCreatedAt()); + } + + @Test + @DisplayName("findById retorna vazio para id inexistente") + void findByIdReturnsEmptyForUnknownId() { + Optional found = chargeRepository.findById(java.util.UUID.randomUUID()); + + assertThat(found).isEmpty(); + } +} From 103675deaa773a20bcf7fcc301fa6486f02e9b28 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Fri, 5 Jun 2026 16:11:47 -0300 Subject: [PATCH 28/38] test(web): integration test POST /charges (201 and 400 problem details) --- .../adapter/in/web/ChargeControllerIT.java | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/test/java/com/lmoraesdev/payment/adapter/in/web/ChargeControllerIT.java diff --git a/src/test/java/com/lmoraesdev/payment/adapter/in/web/ChargeControllerIT.java b/src/test/java/com/lmoraesdev/payment/adapter/in/web/ChargeControllerIT.java new file mode 100644 index 0000000..72855b5 --- /dev/null +++ b/src/test/java/com/lmoraesdev/payment/adapter/in/web/ChargeControllerIT.java @@ -0,0 +1,61 @@ +package com.lmoraesdev.payment.adapter.in.web; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.lmoraesdev.payment.support.TestcontainersConfiguration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(TestcontainersConfiguration.class) +@ActiveProfiles("test") +@DisplayName("ChargeControllerIT") +class ChargeControllerIT { + + @Autowired MockMvc mockMvc; + + @Test + @DisplayName("POST /charges com amount válido → 201 com id, status e createdAt") + void createChargeReturns201() throws Exception { + mockMvc.perform( + post("/charges") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"amount\": 100.00}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").isNotEmpty()) + .andExpect(jsonPath("$.status").value("ACTIVE")) + .andExpect(jsonPath("$.amount").value(100.00)) + .andExpect(jsonPath("$.createdAt").isNotEmpty()); + } + + @Test + @DisplayName("POST /charges com amount negativo → 400 Problem Details com errors.amount") + void createChargeWithNegativeAmountReturns400() throws Exception { + mockMvc.perform( + post("/charges") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"amount\": -1}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.errors.amount").exists()); + } + + @Test + @DisplayName("POST /charges sem amount → 400 Problem Details com errors.amount") + void createChargeWithMissingAmountReturns400() throws Exception { + mockMvc.perform(post("/charges").contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.errors.amount").exists()); + } +} From f8fb4dfd8293d8d996d7453e08cfa2b44e3f73c5 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Fri, 5 Jun 2026 16:14:54 -0300 Subject: [PATCH 29/38] refactor(domain): remove unused PENDING status (charge starts ACTIVE) --- .../java/com/lmoraesdev/payment/domain/model/ChargeStatus.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/lmoraesdev/payment/domain/model/ChargeStatus.java b/src/main/java/com/lmoraesdev/payment/domain/model/ChargeStatus.java index 89790e7..b0620ae 100644 --- a/src/main/java/com/lmoraesdev/payment/domain/model/ChargeStatus.java +++ b/src/main/java/com/lmoraesdev/payment/domain/model/ChargeStatus.java @@ -1,7 +1,6 @@ package com.lmoraesdev.payment.domain.model; public enum ChargeStatus { - PENDING, ACTIVE, PAID, EXPIRED, From db2ec0fa5d38bc6d158b61f5483b9737955883fa Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Fri, 5 Jun 2026 16:20:36 -0300 Subject: [PATCH 30/38] chore(test): make test profile self-sufficient with problemdetails enabled --- src/test/resources/application-test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 1e0afb1..15196f1 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -1,4 +1,7 @@ spring: + mvc: + problemdetails: + enabled: true jpa: hibernate: ddl-auto: create-drop From 429e7e0a28027cd9af4a1bc0e43de30f7b8a99bc Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Fri, 5 Jun 2026 16:21:29 -0300 Subject: [PATCH 31/38] feat(domain): add InvalidAmountException; Money throws domain exception for invalid amount InvalidAmountException extends DomainException (code=INVALID_AMOUNT), so the existing 422 handler picks it up automatically. IllegalArgumentException is now reserved for genuine programming errors that should surface as 500. --- .../domain/exception/InvalidAmountException.java | 8 ++++++++ .../com/lmoraesdev/payment/domain/model/Money.java | 10 +++++----- 2 files changed, 13 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/lmoraesdev/payment/domain/exception/InvalidAmountException.java diff --git a/src/main/java/com/lmoraesdev/payment/domain/exception/InvalidAmountException.java b/src/main/java/com/lmoraesdev/payment/domain/exception/InvalidAmountException.java new file mode 100644 index 0000000..b6b548c --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/domain/exception/InvalidAmountException.java @@ -0,0 +1,8 @@ +package com.lmoraesdev.payment.domain.exception; + +public class InvalidAmountException extends DomainException { + + public InvalidAmountException(String message) { + super("INVALID_AMOUNT", message); + } +} diff --git a/src/main/java/com/lmoraesdev/payment/domain/model/Money.java b/src/main/java/com/lmoraesdev/payment/domain/model/Money.java index 2e5d07c..b99536b 100644 --- a/src/main/java/com/lmoraesdev/payment/domain/model/Money.java +++ b/src/main/java/com/lmoraesdev/payment/domain/model/Money.java @@ -1,15 +1,15 @@ package com.lmoraesdev.payment.domain.model; +import com.lmoraesdev.payment.domain.exception.InvalidAmountException; import java.math.BigDecimal; -import java.util.Objects; public record Money(BigDecimal amount) { public Money { - Objects.requireNonNull(amount, "O valor (amount) não pode ser nulo"); - + if (amount == null) { + throw new InvalidAmountException("amount is required"); + } if (amount.compareTo(BigDecimal.ZERO) <= 0) { - throw new IllegalArgumentException( - "O valor monetário deve ser estritamente maior que zero"); + throw new InvalidAmountException("amount must be greater than zero"); } } } From 401b6d6ff5bc64a5f643836f768bef4bfcc2dfc3 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Fri, 5 Jun 2026 16:22:05 -0300 Subject: [PATCH 32/38] test(domain): update assertions to InvalidAmountException after Money refactor --- .../application/usecase/CreateChargeServiceTest.java | 9 +++++---- .../com/lmoraesdev/payment/domain/model/MoneyTest.java | 5 +++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/test/java/com/lmoraesdev/payment/application/usecase/CreateChargeServiceTest.java b/src/test/java/com/lmoraesdev/payment/application/usecase/CreateChargeServiceTest.java index 62347c0..3968028 100644 --- a/src/test/java/com/lmoraesdev/payment/application/usecase/CreateChargeServiceTest.java +++ b/src/test/java/com/lmoraesdev/payment/application/usecase/CreateChargeServiceTest.java @@ -9,6 +9,7 @@ import com.lmoraesdev.payment.application.port.in.CreateChargeCommand; import com.lmoraesdev.payment.application.port.in.CreateChargeResult; import com.lmoraesdev.payment.application.port.out.ChargeRepository; +import com.lmoraesdev.payment.domain.exception.InvalidAmountException; import com.lmoraesdev.payment.domain.model.ChargeStatus; import java.math.BigDecimal; import java.util.stream.Stream; @@ -60,16 +61,16 @@ void createsChargeSuccessfully(Case c) { } @Test - @DisplayName("propaga exceção para amount zero ou negativo") + @DisplayName("propaga InvalidAmountException para amount zero ou negativo") void propagatesExceptionForInvalidAmount() { assertThatThrownBy(() -> service.create(new CreateChargeCommand(BigDecimal.ZERO))) - .isInstanceOf(IllegalArgumentException.class); + .isInstanceOf(InvalidAmountException.class); } @Test - @DisplayName("propaga exceção para amount nulo") + @DisplayName("propaga InvalidAmountException para amount nulo") void propagatesExceptionForNullAmount() { assertThatThrownBy(() -> service.create(new CreateChargeCommand(null))) - .isInstanceOf(NullPointerException.class); + .isInstanceOf(InvalidAmountException.class); } } diff --git a/src/test/java/com/lmoraesdev/payment/domain/model/MoneyTest.java b/src/test/java/com/lmoraesdev/payment/domain/model/MoneyTest.java index 9d5ea3b..b06c5c3 100644 --- a/src/test/java/com/lmoraesdev/payment/domain/model/MoneyTest.java +++ b/src/test/java/com/lmoraesdev/payment/domain/model/MoneyTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThatNoException; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.lmoraesdev.payment.domain.exception.InvalidAmountException; import java.math.BigDecimal; import java.util.stream.Stream; import org.junit.jupiter.api.DisplayName; @@ -37,13 +38,13 @@ void validates(Case c) { if (c.valid()) { assertThatNoException().isThrownBy(() -> new Money(value)); } else { - assertThatThrownBy(() -> new Money(value)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new Money(value)).isInstanceOf(InvalidAmountException.class); } } @Test @DisplayName("rejeita amount nulo") void rejectsNull() { - assertThatThrownBy(() -> new Money(null)).isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> new Money(null)).isInstanceOf(InvalidAmountException.class); } } From 3ff16968b39020873f1fcff9bb8efab8fe083daf Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Fri, 5 Jun 2026 17:18:40 -0300 Subject: [PATCH 33/38] fix(style): resolve Checkstyle violations in ChargeJpaEntity and Charge - Expand jakarta.persistence.* star import to explicit imports - Add braces to single-line if statements in Charge.equals() --- .../payment/adapter/out/persistence/ChargeJpaEntity.java | 7 ++++++- .../java/com/lmoraesdev/payment/domain/model/Charge.java | 8 ++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeJpaEntity.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeJpaEntity.java index ed05976..e079e1a 100644 --- a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeJpaEntity.java +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeJpaEntity.java @@ -1,7 +1,12 @@ package com.lmoraesdev.payment.adapter.out.persistence; import com.lmoraesdev.payment.domain.model.ChargeStatus; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; import java.math.BigDecimal; import java.time.Instant; import java.util.UUID; diff --git a/src/main/java/com/lmoraesdev/payment/domain/model/Charge.java b/src/main/java/com/lmoraesdev/payment/domain/model/Charge.java index b106d89..5e4544f 100644 --- a/src/main/java/com/lmoraesdev/payment/domain/model/Charge.java +++ b/src/main/java/com/lmoraesdev/payment/domain/model/Charge.java @@ -50,8 +50,12 @@ public Instant getCreatedAt() { @Override public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof Charge other)) return false; + if (this == o) { + return true; + } + if (!(o instanceof Charge other)) { + return false; + } return id.equals(other.id); } From 89d3c130a4fa66fc715aff044b7458029f6aa786 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Fri, 5 Jun 2026 17:18:43 -0300 Subject: [PATCH 34/38] test(support): align Testcontainers Postgres version with production (postgres:18-alpine) --- .../lmoraesdev/payment/support/TestcontainersConfiguration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/lmoraesdev/payment/support/TestcontainersConfiguration.java b/src/test/java/com/lmoraesdev/payment/support/TestcontainersConfiguration.java index b8dc856..40be1e7 100644 --- a/src/test/java/com/lmoraesdev/payment/support/TestcontainersConfiguration.java +++ b/src/test/java/com/lmoraesdev/payment/support/TestcontainersConfiguration.java @@ -11,6 +11,6 @@ public class TestcontainersConfiguration { @Bean @ServiceConnection PostgreSQLContainer postgresContainer() { - return new PostgreSQLContainer<>("postgres:17-alpine"); + return new PostgreSQLContainer<>("postgres:18-alpine"); } } From d018ba790e45ec08df15853f76a5ff1133a42dbf Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Fri, 5 Jun 2026 18:48:16 -0300 Subject: [PATCH 35/38] feat(docs): add SpringDoc OpenAPI 2.8.17 with Swagger UI - springdoc-openapi-starter-webmvc-ui 2.8.17 - OpenApiConfig with project info and contact - @Tag / @Operation / @ApiResponses on ChargeController (201, 400, 422, 500) - @Schema on CreateChargeRequest and CreateChargeResponse with examples - springdoc paths configured in application.yml - Swagger UI: /swagger-ui.html | Spec: /v3/api-docs --- pom.xml | 7 +++++ .../adapter/in/web/ChargeController.java | 29 +++++++++++++++++++ .../adapter/in/web/CreateChargeRequest.java | 8 ++++- .../adapter/in/web/CreateChargeResponse.java | 8 ++++- .../payment/config/OpenApiConfig.java | 26 +++++++++++++++++ src/main/resources/application.yml | 7 +++++ 6 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/lmoraesdev/payment/config/OpenApiConfig.java diff --git a/pom.xml b/pom.xml index a0958e9..4fe6da7 100644 --- a/pom.xml +++ b/pom.xml @@ -84,6 +84,13 @@ true + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.17 + + org.springframework.boot 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 e4f7ba3..dfc023c 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 @@ -3,14 +3,22 @@ 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 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.HttpStatus; +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; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "Charges", description = "Gerenciamento de cobranças Pix") @RestController @RequestMapping("/charges") public class ChargeController { @@ -20,6 +28,27 @@ public ChargeController(CreateCharge createCharge) { this.createCharge = createCharge; } + @Operation( + summary = "Criar cobrança", + description = "Cria uma nova cobrança Pix com status ACTIVE") + @ApiResponses({ + @ApiResponse( + responseCode = "201", + description = "Cobrança criada com sucesso", + content = @Content(schema = @Schema(implementation = CreateChargeResponse.class))), + @ApiResponse( + responseCode = "400", + description = "Dados inválidos — Problem Details com erros por campo", + content = @Content(schema = @Schema(implementation = ProblemDetail.class))), + @ApiResponse( + responseCode = "422", + description = "Regra de negócio violada", + content = @Content(schema = @Schema(implementation = ProblemDetail.class))), + @ApiResponse( + responseCode = "500", + description = "Erro interno inesperado", + content = @Content(schema = @Schema(implementation = ProblemDetail.class))) + }) @PostMapping public ResponseEntity create( @Valid @RequestBody CreateChargeRequest request) { diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeRequest.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeRequest.java index 2861ec5..c81ad2d 100644 --- a/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeRequest.java +++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeRequest.java @@ -1,7 +1,13 @@ package com.lmoraesdev.payment.adapter.in.web; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import java.math.BigDecimal; -public record CreateChargeRequest(@NotNull @Positive BigDecimal amount) {} +@Schema(description = "Requisição para criar uma cobrança Pix") +public record CreateChargeRequest( + @Schema(description = "Valor da cobrança em reais", example = "150.00", minimum = "0.01") + @NotNull + @Positive + BigDecimal amount) {} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeResponse.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeResponse.java index 15c1d85..b64b692 100644 --- a/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeResponse.java +++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/CreateChargeResponse.java @@ -1,7 +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; -public record CreateChargeResponse(UUID id, String status, BigDecimal amount, Instant createdAt) {} +@Schema(description = "Cobrança Pix criada") +public record CreateChargeResponse( + @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/config/OpenApiConfig.java b/src/main/java/com/lmoraesdev/payment/config/OpenApiConfig.java new file mode 100644 index 0000000..f977486 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/config/OpenApiConfig.java @@ -0,0 +1,26 @@ +package com.lmoraesdev.payment.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info( + new Info() + .title("Payment API") + .description( + "Core de pagamentos Pix — Hexagonal Architecture, Spring Boot 3, PostgreSQL 18") + .version("0.1.0") + .contact( + new Contact() + .name("Leandro Moraes") + .url("https://github.com/lmoraesdev"))); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0f96c98..c77719d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -47,6 +47,13 @@ management: tracing: endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318/v1/traces} +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html + operations-sorter: method + logging: level: root: INFO From 3928193f70f9cb6bca3e551b57de07f529d28519 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Fri, 5 Jun 2026 18:48:21 -0300 Subject: [PATCH 36/38] docs: update README to reflect EPIC-001 completion - Status table with EPIC progress (001 done, 002/003/008 roadmap) - Updated stack table with SpringDoc 2.8.17 - Accurate package tree showing implemented classes - API section with request/response examples and error format - Endpoints table includes Swagger UI and OpenAPI spec URLs - Tests table documenting each test class and coverage - Roadmap replaced by EPIC status table --- README.md | 135 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 78 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index c85bb7e..ceb6b9c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # payment-api -Fundação de um core de pagamentos Pix em Java — infraestrutura, observabilidade e pipeline CI/CD prontos; domínio em construção. +Core de pagamentos Pix em Java — Hexagonal Architecture, observabilidade e pipeline CI/CD. EPIC-001 (criar cobrança) implementado e testado. [![CI](https://github.com/lmoraesdev/java-payment-hexagonal/actions/workflows/ci.yml/badge.svg)](https://github.com/lmoraesdev/java-payment-hexagonal/actions/workflows/ci.yml) ![Java](https://img.shields.io/badge/Java-21-blue?logo=openjdk&logoColor=white) @@ -8,7 +8,12 @@ Fundação de um core de pagamentos Pix em Java — infraestrutura, observabilid ## Status -A plataforma está completa: serviços sobem, métricas chegam no Grafana, traces no Jaeger, testes de integração passam com Testcontainers. O que ainda não existe é o domínio de negócio — entidades, casos de uso, portas e adapters de persistência/mensageria. Esses são os próximos passos (ver [Roadmap](#roadmap)). +| 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 @@ -19,9 +24,10 @@ A plataforma está completa: serviços sobem, métricas chegam no Grafana, trace | 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 banco real | +| Testcontainers | 1.21 | Testes de integração com PostgreSQL 18 real | | Spotless (GJF AOSP) | 2.43 | Formatação automática de código | | Checkstyle | 3.5 | Verificação de estilo | @@ -29,94 +35,124 @@ A plataforma está completa: serviços sobem, métricas chegam no Grafana, trace 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. -Decisões de projeto: -- **Observabilidade desde o início** — Prometheus, Grafana e Jaeger estão na infra antes do primeiro use case existir. Métricas e traces não são afterthought. -- **Logging estruturado 5W1H** — cada log emite JSON com `where`, `why`, `when`, `who`, `what`, `how` + `traceId`/`spanId` injetados automaticamente pelo Micrometer MDC. Facilita correlação em produção. -- **Event-driven preparado** — Kafka configurado com KRaft (sem Zookeeper), consumer/producer prontos no `application.yml`. Nenhum evento publicado ainda. - ``` com.lmoraesdev.payment ├── adapter -│ ├── in.web ← PingController, GlobalExceptionHandler -│ └── out -│ ├── messaging ← (roadmap — Kafka producers) -│ └── persistence ← (roadmap — JPA repositories) +│ ├── in.web ← ChargeController, GlobalExceptionHandler, DTOs +│ └── out.persistence ← ChargeJpaEntity, ChargeMapper, ChargeRepositoryAdapter ├── application -│ ├── port.in ← (roadmap — interfaces de entrada) -│ ├── port.out ← (roadmap — interfaces de saída) -│ └── usecase ← (roadmap — casos de uso) +│ ├── port.in ← CreateCharge (interface), CreateChargeCommand, CreateChargeResult +│ ├── port.out ← ChargeRepository (interface) +│ └── usecase ← CreateChargeService ├── config -│ └── logging ← Log5w1h, Logger5w1hBuilder +│ ├── logging ← Log5w1h, Logger5w1hBuilder (structured 5W1H logging) +│ └── OpenApiConfig ← SpringDoc / Swagger UI └── domain - ├── event ← (roadmap — domain events) - ├── exception ← DomainException (base abstrata com código de erro) - └── model ← (roadmap — entidades e value objects) + ├── exception ← DomainException (base), InvalidAmountException + └── model ← Charge, Money, ChargeStatus ``` -Especificação do domínio planejado (Charge Pix, máquina de estados, Money, idempotência): [`docs/architecture.md`](docs/architecture.md). +Decisões de projeto: +- **Domínio puro** — `Charge`, `Money`, `ChargeStatus` sem nenhuma anotação de framework +- **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 ## Como rodar -**Pré-requisitos:** Docker Desktop com WSL2 integration habilitada; contexto Docker configurado para `default` (`docker context use default`). +**Pré-requisitos:** Docker Desktop com WSL2 integration habilitada; contexto Docker configurado para `default`. ```bash # 1. Variáveis de ambiente cp .env.example .env -# 2. Subir infra + app (sem Redis) +# 2. Subir infra + app make up -# equivalente: docker compose up -d --build # 3. Subir com Redis (profile cache) docker compose --profile cache up -d --build +``` + +## API + +### Criar cobrança + +```http +POST /charges +Content-Type: application/json + +{"amount": 150.00} +``` + +**201 Created** +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "status": "ACTIVE", + "amount": 150.00, + "createdAt": "2025-06-05T18:00:00Z" +} +``` -# 4. Verificar status -docker compose ps +**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..." +} ``` ## Endpoints e observabilidade | 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:8090` | Kafka UI — tópicos, consumer groups, mensagens | | `http://localhost:9090` | Prometheus — séries temporais, targets ativos | | `http://localhost:3000` | Grafana — dashboard "Payment Overview" (admin/admin) | | `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 -make test -# equivalente: ./mvnw test +# Unitários — sem Docker, rápido (~2s) +./mvnw test -# Integração — sobe PostgreSQL 18 via Testcontainers -make verify -# equivalente: ./mvnw verify +# 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` — testes unitários, executados pelo Surefire -- `*IT.java` — testes de integração, executados pelo Failsafe +- `*Test.java` — unitários, Surefire +- `*IT.java` — integração, Failsafe + Testcontainers -O teste `PaymentApiApplicationIT` valida que o contexto Spring sobe corretamente contra um banco PostgreSQL real, sem mocks. +Relatório JaCoCo gerado em `target/site/jacoco/index.html` após `./mvnw verify`. ## CI/CD | Trigger | Job | O que roda | |---|---|---| -| Push para `develop` ou `main` | Lint + Unit Tests | `spotless:check` → `checkstyle:check` → `mvnw test` | +| 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` | -O job de integração roda apenas no caminho para `main`, mantendo o ciclo de feedback rápido no `develop`. - ## Padrões -**Formatação e estilo:** - ```bash ./mvnw spotless:apply # formata (Google Java Format, AOSP 4-space) ./mvnw spotless:check # verifica (roda no CI) @@ -124,7 +160,6 @@ O job de integração roda apenas no caminho para `main`, mantendo o ciclo de fe ``` **Git hooks** (ativar uma vez por clone): - ```bash git config core.hooksPath .githooks ``` @@ -134,13 +169,12 @@ git config core.hooksPath .githooks | `commit-msg` | Valida formato Conventional Commits | | `pre-push` | Executa `./mvnw verify` antes de subir | -Formato de commit: `tipo(escopo): descrição` — tipos aceitos: `feat fix docs style refactor test chore build ci perf revert`. +Formato de commit: `tipo(escopo): descrição` — tipos: `feat fix docs style refactor test chore build ci perf revert`. **Makefile:** - ``` make up # docker compose up -d --build -make down # docker compose down (mantém volumes) +make down # docker compose down make clean # docker compose down -v (remove volumes) make logs # docker compose logs -f app make test # ./mvnw test @@ -149,19 +183,6 @@ make format # ./mvnw spotless:apply make db # psql no container postgres ``` -## Roadmap - -O que está especificado em [`docs/architecture.md`](docs/architecture.md) e ainda não implementado: - -- [ ] Entidade `Charge` com value object `Money` e `ChargeStatus` -- [ ] Máquina de estados (`PENDING → ACTIVE → PAID / EXPIRED / CANCELLED`) -- [ ] Caso de uso `CreateCharge` com idempotência por header -- [ ] Adapter de persistência JPA (`ChargeRepository`) -- [ ] Publicação de eventos de domínio no Kafka (`ChargeCreated`, `ChargePaid`, etc.) -- [ ] Consumer para eventos externos (webhook/notificação) -- [ ] Uso do Redis para cache de idempotência e locks -- [ ] Endpoints REST de cobrança (`POST /charges`, `GET /charges/{id}`) - --- [Leandro Moraes](https://github.com/lmoraesdev) From e164271561e945c82adc8cc87f3804b17de6d2a2 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Sat, 6 Jun 2026 19:19:49 -0300 Subject: [PATCH 37/38] fix(observability): set application name for Prometheus and Jaeger spring.application.name=payment-api added to application.yml (default for host-mode dev) and SPRING_APPLICATION_NAME env var added to docker-compose.yml (immediate effect on the running container without rebuild). Fixes Jaeger reporting traces as unknown_service. --- docker-compose.yml | 1 + src/main/resources/application.yml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index cdd763b..989b726 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,7 @@ services: SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB} SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER} SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} + SPRING_APPLICATION_NAME: payment-api SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:29092 # Traces vão para o Jaeger via OTLP HTTP OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4318/v1/traces diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c77719d..b4b1255 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,4 +1,6 @@ spring: + application: + name: payment-api datasource: url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/payment_db} username: ${SPRING_DATASOURCE_USERNAME:admin} From b0c2006213f2ee3c65925b56693b6221f3288c67 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Sat, 6 Jun 2026 19:19:58 -0300 Subject: [PATCH 38/38] refactor(domain): store monetary amounts as BIGINT centavos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Banking systems must not use floating-point for money. Changes: - Money: compact constructor now normalises to scale=2 (RoundingMode.UNNECESSARY); amounts with more than 2 decimal places throw InvalidAmountException. - ChargeJpaEntity: BigDecimal amount → Long amountCentavos (BIGINT in PostgreSQL). Column renamed to amount_centavos for self-documentation. - ChargeMapper: toEntity converts via movePointRight(2).longValueExact(); toDomain reconstructs via BigDecimal.valueOf(centavos, 2). - README: documents the centavos storage decision and corrects minor inaccuracies. All 21 tests (16 unit + 5 IT) green. Schema verified: amount_centavos bigint. --- README.md | 7 +++++-- .../adapter/out/persistence/ChargeJpaEntity.java | 13 ++++++------- .../adapter/out/persistence/ChargeMapper.java | 5 +++-- .../com/lmoraesdev/payment/domain/model/Money.java | 6 ++++++ 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index ceb6b9c..59b633c 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ com.lmoraesdev.payment 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` @@ -116,7 +117,7 @@ Content-Type: application/json | `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:3000` | Grafana — dashboard "Payment Overview" (admin/admin) | +| `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 | @@ -159,7 +160,7 @@ Relatório JaCoCo gerado em `target/site/jacoco/index.html` após `./mvnw verify ./mvnw checkstyle:check # estilo (roda no CI) ``` -**Git hooks** (ativar uma vez por clone): +**Git hooks** — shell scripts em `.githooks/` (ativar uma vez por clone): ```bash git config core.hooksPath .githooks ``` @@ -171,6 +172,8 @@ git config core.hooksPath .githooks 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 diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeJpaEntity.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeJpaEntity.java index e079e1a..23bea11 100644 --- a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeJpaEntity.java +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeJpaEntity.java @@ -7,7 +7,6 @@ import jakarta.persistence.Enumerated; import jakarta.persistence.Id; import jakarta.persistence.Table; -import java.math.BigDecimal; import java.time.Instant; import java.util.UUID; @@ -16,8 +15,8 @@ public class ChargeJpaEntity { @Id private UUID id; - @Column(nullable = false) - private BigDecimal amount; + @Column(name = "amount_centavos", nullable = false) + private Long amountCentavos; @Enumerated(EnumType.STRING) @Column(nullable = false) @@ -36,12 +35,12 @@ public void setId(UUID id) { this.id = id; } - public BigDecimal getAmount() { - return amount; + public Long getAmountCentavos() { + return amountCentavos; } - public void setAmount(BigDecimal amount) { - this.amount = amount; + public void setAmountCentavos(Long amountCentavos) { + this.amountCentavos = amountCentavos; } public ChargeStatus getStatus() { diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeMapper.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeMapper.java index 51d0229..0dacf77 100644 --- a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeMapper.java +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeMapper.java @@ -2,6 +2,7 @@ import com.lmoraesdev.payment.domain.model.Charge; import com.lmoraesdev.payment.domain.model.Money; +import java.math.BigDecimal; final class ChargeMapper { private ChargeMapper() {} @@ -10,7 +11,7 @@ static ChargeJpaEntity toEntity(Charge charge) { ChargeJpaEntity entity = new ChargeJpaEntity(); entity.setId(charge.getId()); - entity.setAmount(charge.getAmount().amount()); + entity.setAmountCentavos(charge.getAmount().amount().movePointRight(2).longValueExact()); entity.setStatus(charge.getStatus()); entity.setCreatedAt(charge.getCreatedAt()); @@ -21,7 +22,7 @@ static Charge toDomain(ChargeJpaEntity entity) { return Charge.restore( entity.getId(), - new Money(entity.getAmount()), + new Money(BigDecimal.valueOf(entity.getAmountCentavos(), 2)), entity.getStatus(), entity.getCreatedAt()); } diff --git a/src/main/java/com/lmoraesdev/payment/domain/model/Money.java b/src/main/java/com/lmoraesdev/payment/domain/model/Money.java index b99536b..9b2b6bc 100644 --- a/src/main/java/com/lmoraesdev/payment/domain/model/Money.java +++ b/src/main/java/com/lmoraesdev/payment/domain/model/Money.java @@ -2,6 +2,7 @@ import com.lmoraesdev.payment.domain.exception.InvalidAmountException; import java.math.BigDecimal; +import java.math.RoundingMode; public record Money(BigDecimal amount) { public Money { @@ -11,5 +12,10 @@ public record Money(BigDecimal amount) { if (amount.compareTo(BigDecimal.ZERO) <= 0) { throw new InvalidAmountException("amount must be greater than zero"); } + try { + amount = amount.setScale(2, RoundingMode.UNNECESSARY); + } catch (ArithmeticException e) { + throw new InvalidAmountException("amount must have at most 2 decimal places"); + } } }