Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
push:
branches: ["epic/**", develop, main]
pull_request:
branches: [main]
branches: [main, develop]

permissions:
contents: read
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CreateChargeResponse> create(
@Valid @RequestBody CreateChargeRequest request) {
@Override
public ResponseEntity<CreateChargeResponse> create(CreateChargeRequest request) {
CreateChargeResult result = createCharge.create(new CreateChargeCommand(request.amount()));

CreateChargeResponse response =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.lmoraesdev.payment.adapter.in.web.documentation;

import com.lmoraesdev.payment.adapter.in.web.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<CreateChargeResponse> create(@Valid @RequestBody CreateChargeRequest request);
}
Empty file.
Original file line number Diff line number Diff line change
@@ -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<Object, Object> kafkaTemplate;

public OutboxRelay(
OutboxEventJpaRepository repository, KafkaTemplate<Object, Object> kafkaTemplate) {
this.repository = repository;
this.kafkaTemplate = kafkaTemplate;
}

@Scheduled(fixedDelay = 5000)
public void publishPending() {
List<OutboxEventEntity> 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);
});
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<OutboxEventEntity, UUID> {

List<OutboxEventEntity> findTop50ByStatusOrderByCreatedAtAsc(OutboxStatus status);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.lmoraesdev.payment.adapter.out.persistence.outbox;

public enum OutboxStatus {
PENDING,
PUBLISHED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.lmoraesdev.payment.application.port.out;

public interface OutboxEventPort {

void record(String aggregateType, String aggregateId, String eventType, Object payload);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {}
Loading
Loading