diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b93539e..2a5bbbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ on: push: branches: ["epic/**", develop, main] pull_request: - branches: [main] + branches: [main, develop] permissions: contents: read diff --git a/src/main/java/com/lmoraesdev/payment/PaymentApiApplication.java b/src/main/java/com/lmoraesdev/payment/PaymentApiApplication.java index 946f3ab..31c644e 100644 --- a/src/main/java/com/lmoraesdev/payment/PaymentApiApplication.java +++ b/src/main/java/com/lmoraesdev/payment/PaymentApiApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class PaymentApiApplication { public static void main(String[] args) { 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 dfc023c..5d20dd5 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,57 +1,23 @@ package com.lmoraesdev.payment.adapter.in.web; +import com.lmoraesdev.payment.adapter.in.web.documentation.ChargeControllerDoc; 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 { +public class ChargeController implements ChargeControllerDoc { private final CreateCharge createCharge; 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) { + @Override + public ResponseEntity create(CreateChargeRequest request) { CreateChargeResult result = createCharge.create(new CreateChargeCommand(request.amount())); CreateChargeResponse response = diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/documentation/ChargeControllerDoc.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/documentation/ChargeControllerDoc.java new file mode 100644 index 0000000..c4fdc93 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/documentation/ChargeControllerDoc.java @@ -0,0 +1,45 @@ +package com.lmoraesdev.payment.adapter.in.web.documentation; + +import com.lmoraesdev.payment.adapter.in.web.CreateChargeRequest; +import com.lmoraesdev.payment.adapter.in.web.CreateChargeResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +@Tag(name = "Charges", description = "Gerenciamento de cobranças Pix") +@RequestMapping("/charges") +public interface ChargeControllerDoc { + + @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 + ResponseEntity create(@Valid @RequestBody CreateChargeRequest request); +} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/messaging/.gitkeep b/src/main/java/com/lmoraesdev/payment/adapter/out/messaging/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/messaging/OutboxRelay.java b/src/main/java/com/lmoraesdev/payment/adapter/out/messaging/OutboxRelay.java new file mode 100644 index 0000000..0b686fb --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/messaging/OutboxRelay.java @@ -0,0 +1,59 @@ +package com.lmoraesdev.payment.adapter.out.messaging; + +import com.lmoraesdev.payment.adapter.out.persistence.outbox.OutboxEventEntity; +import com.lmoraesdev.payment.adapter.out.persistence.outbox.OutboxEventJpaRepository; +import com.lmoraesdev.payment.adapter.out.persistence.outbox.OutboxStatus; +import java.util.List; +import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class OutboxRelay { + + private static final Logger LOG = LoggerFactory.getLogger(OutboxRelay.class); + private static final String TOPIC = "payments.charge-created"; + + private final OutboxEventJpaRepository repository; + private final KafkaTemplate kafkaTemplate; + + public OutboxRelay( + OutboxEventJpaRepository repository, KafkaTemplate kafkaTemplate) { + this.repository = repository; + this.kafkaTemplate = kafkaTemplate; + } + + @Scheduled(fixedDelay = 5000) + public void publishPending() { + List pending = + repository.findTop50ByStatusOrderByCreatedAtAsc(OutboxStatus.PENDING); + + for (OutboxEventEntity event : pending) { + publish(event); + } + } + + private void publish(OutboxEventEntity event) { + try { + kafkaTemplate.send(TOPIC, event.getAggregateId(), event.getPayload()).get(); + markPublished(event.getId()); + } catch (Exception e) { + LOG.warn("failed to publish outbox event {}, will retry next poll", event.getId(), e); + } + } + + @Transactional + public void markPublished(UUID eventId) { + repository + .findById(eventId) + .ifPresent( + event -> { + event.markPublished(); + repository.save(event); + }); + } +} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventAdapter.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventAdapter.java new file mode 100644 index 0000000..a1c56b8 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventAdapter.java @@ -0,0 +1,33 @@ +package com.lmoraesdev.payment.adapter.out.persistence.outbox; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lmoraesdev.payment.application.port.out.OutboxEventPort; +import org.springframework.stereotype.Component; + +@Component +public class OutboxEventAdapter implements OutboxEventPort { + + private final OutboxEventJpaRepository repository; + private final ObjectMapper objectMapper; + + public OutboxEventAdapter(OutboxEventJpaRepository repository, ObjectMapper objectMapper) { + this.repository = repository; + this.objectMapper = objectMapper; + } + + @Override + public void record(String aggregateType, String aggregateId, String eventType, Object payload) { + OutboxEventEntity event = + OutboxEventEntity.pending(aggregateType, aggregateId, eventType, toJson(payload)); + repository.save(event); + } + + private String toJson(Object payload) { + try { + return objectMapper.writeValueAsString(payload); + } catch (JsonProcessingException e) { + throw new IllegalStateException("failed to serialize outbox payload", e); + } + } +} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventEntity.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventEntity.java new file mode 100644 index 0000000..67445b0 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventEntity.java @@ -0,0 +1,87 @@ +package com.lmoraesdev.payment.adapter.out.persistence.outbox; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.UUID; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Entity +@Table( + name = "outbox_events", + indexes = @Index(name = "idx_outbox_status_created_at", columnList = "status, created_at")) +public class OutboxEventEntity { + + @Id private UUID id; + + @Column(name = "aggregate_type", nullable = false, updatable = false) + private String aggregateType; + + @Column(name = "aggregate_id", nullable = false, updatable = false) + private String aggregateId; + + @Column(name = "event_type", nullable = false, updatable = false) + private String eventType; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(nullable = false, updatable = false, columnDefinition = "jsonb") + private String payload; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OutboxStatus status; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "published_at") + private Instant publishedAt; + + protected OutboxEventEntity() { + // JPA + } + + public static OutboxEventEntity pending( + String aggregateType, String aggregateId, String eventType, String payloadJson) { + OutboxEventEntity event = new OutboxEventEntity(); + event.id = UUID.randomUUID(); + event.aggregateType = aggregateType; + event.aggregateId = aggregateId; + event.eventType = eventType; + event.payload = payloadJson; + event.status = OutboxStatus.PENDING; + event.createdAt = Instant.now(); + return event; + } + + public void markPublished() { + this.status = OutboxStatus.PUBLISHED; + this.publishedAt = Instant.now(); + } + + public UUID getId() { + return id; + } + + public String getAggregateId() { + return aggregateId; + } + + public String getEventType() { + return eventType; + } + + public String getPayload() { + return payload; + } + + public OutboxStatus getStatus() { + return status; + } +} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventJpaRepository.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventJpaRepository.java new file mode 100644 index 0000000..b30a97f --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventJpaRepository.java @@ -0,0 +1,10 @@ +package com.lmoraesdev.payment.adapter.out.persistence.outbox; + +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OutboxEventJpaRepository extends JpaRepository { + + List findTop50ByStatusOrderByCreatedAtAsc(OutboxStatus status); +} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxStatus.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxStatus.java new file mode 100644 index 0000000..88e609e --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxStatus.java @@ -0,0 +1,6 @@ +package com.lmoraesdev.payment.adapter.out.persistence.outbox; + +public enum OutboxStatus { + PENDING, + PUBLISHED +} diff --git a/src/main/java/com/lmoraesdev/payment/application/port/out/OutboxEventPort.java b/src/main/java/com/lmoraesdev/payment/application/port/out/OutboxEventPort.java new file mode 100644 index 0000000..869ca5f --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/application/port/out/OutboxEventPort.java @@ -0,0 +1,6 @@ +package com.lmoraesdev.payment.application.port.out; + +public interface OutboxEventPort { + + void record(String aggregateType, String aggregateId, String eventType, Object payload); +} 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 c309a43..97bd222 100644 --- a/src/main/java/com/lmoraesdev/payment/application/usecase/CreateChargeService.java +++ b/src/main/java/com/lmoraesdev/payment/application/usecase/CreateChargeService.java @@ -4,20 +4,26 @@ 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.application.port.out.OutboxEventPort; import com.lmoraesdev.payment.config.logging.Logger5w1hBuilder; +import com.lmoraesdev.payment.domain.event.ChargeCreatedEvent; import com.lmoraesdev.payment.domain.model.Charge; import com.lmoraesdev.payment.domain.model.Money; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service public class CreateChargeService implements CreateCharge { private final ChargeRepository chargeRepository; + private final OutboxEventPort outboxEventPort; - public CreateChargeService(ChargeRepository chargeRepository) { + public CreateChargeService(ChargeRepository chargeRepository, OutboxEventPort outboxEventPort) { this.chargeRepository = chargeRepository; + this.outboxEventPort = outboxEventPort; } @Override + @Transactional public CreateChargeResult create(CreateChargeCommand command) { Money amount = new Money(command.amount()); @@ -26,6 +32,13 @@ public CreateChargeResult create(CreateChargeCommand command) { Charge saved = chargeRepository.save(charge); + outboxEventPort.record( + "Charge", + saved.getId().toString(), + "ChargeCreated", + new ChargeCreatedEvent( + saved.getId(), saved.getAmount().amount(), saved.getCreatedAt())); + Logger5w1hBuilder.create(CreateChargeService.class) .where("CreateChargeService") .what("charge_created") diff --git a/src/main/java/com/lmoraesdev/payment/domain/event/ChargeCreatedEvent.java b/src/main/java/com/lmoraesdev/payment/domain/event/ChargeCreatedEvent.java new file mode 100644 index 0000000..55b5d00 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/domain/event/ChargeCreatedEvent.java @@ -0,0 +1,7 @@ +package com.lmoraesdev.payment.domain.event; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +public record ChargeCreatedEvent(UUID chargeId, BigDecimal amount, Instant occurredAt) {} 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 3968028..4837df0 100644 --- a/src/test/java/com/lmoraesdev/payment/application/usecase/CreateChargeServiceTest.java +++ b/src/test/java/com/lmoraesdev/payment/application/usecase/CreateChargeServiceTest.java @@ -3,12 +3,14 @@ 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.ArgumentMatchers.eq; 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.application.port.out.OutboxEventPort; import com.lmoraesdev.payment.domain.exception.InvalidAmountException; import com.lmoraesdev.payment.domain.model.ChargeStatus; import java.math.BigDecimal; @@ -28,6 +30,8 @@ class CreateChargeServiceTest { @Mock ChargeRepository chargeRepository; + @Mock OutboxEventPort outboxEventPort; + @InjectMocks CreateChargeService service; record Case(String name, String amount) { @@ -58,6 +62,7 @@ void createsChargeSuccessfully(Case c) { assertThat(result.amount()).isEqualByComparingTo(c.amount()); assertThat(result.createdAt()).isNotNull(); verify(chargeRepository).save(any()); + verify(outboxEventPort).record(eq("Charge"), any(), eq("ChargeCreated"), any()); } @Test