feat: otimização de performance e ajustes finais

This commit is contained in:
Idrissa Banora
2026-05-18 10:49:32 +00:00
commit 52a7c4f9cf
579 changed files with 156489 additions and 0 deletions
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>br.gov.sigefp</groupId>
<artifactId>sigefp-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>sigefp-treasury</artifactId>
<packaging>jar</packaging>
<name>SIGEFP Treasury</name>
<description>Módulo de tesouraria/pagamentos</description>
<dependencies>
<dependency>
<groupId>br.gov.sigefp</groupId>
<artifactId>sigefp-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>br.gov.sigefp</groupId>
<artifactId>sigefp-budget</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>br.gov.sigefp</groupId>
<artifactId>sigefp-rh</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,74 @@
package br.gov.sigefp.treasury.api;
import br.gov.sigefp.treasury.api.dto.BankReconciliationDTO;
import br.gov.sigefp.treasury.api.dto.CreateBankReconciliationDTO;
import br.gov.sigefp.treasury.api.dto.ReconciliationItemDTO;
import br.gov.sigefp.treasury.service.BankReconciliationService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/treasury/reconciliations")
@RequiredArgsConstructor
public class BankReconciliationController {
private final BankReconciliationService reconciliationService;
@PostMapping
public ResponseEntity<BankReconciliationDTO> importStatement(
@Valid @RequestBody CreateBankReconciliationDTO dto) {
BankReconciliationDTO created = reconciliationService.importStatement(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@PostMapping("/{id}/reconcile")
public ResponseEntity<BankReconciliationDTO> reconcile(@PathVariable UUID id) {
BankReconciliationDTO updated = reconciliationService.reconcile(id);
return ResponseEntity.ok(updated);
}
@PostMapping("/{id}/match-item")
public ResponseEntity<Void> matchItem(
@PathVariable UUID id,
@RequestParam UUID itemId,
@RequestParam UUID systemTransactionId) {
reconciliationService.matchItem(id, itemId, systemTransactionId);
return ResponseEntity.ok().build();
}
@PostMapping("/{id}/finalize")
public ResponseEntity<BankReconciliationDTO> finalize(
@PathVariable UUID id,
@RequestParam UUID reconciledBy) {
BankReconciliationDTO updated = reconciliationService.finalize(id, reconciledBy);
return ResponseEntity.ok(updated);
}
@GetMapping("/{id}")
public ResponseEntity<BankReconciliationDTO> findById(@PathVariable UUID id) {
BankReconciliationDTO reconciliation = reconciliationService.findById(id);
return ResponseEntity.ok(reconciliation);
}
@GetMapping("/cash-account/{cashAccountId}")
public ResponseEntity<List<BankReconciliationDTO>> findByCashAccountId(
@PathVariable UUID cashAccountId) {
List<BankReconciliationDTO> reconciliations = reconciliationService.findByCashAccountId(cashAccountId);
return ResponseEntity.ok(reconciliations);
}
@GetMapping("/{id}/unmatched-items")
public ResponseEntity<List<ReconciliationItemDTO>> findUnmatchedItems(
@PathVariable UUID id) {
List<ReconciliationItemDTO> items = reconciliationService.findUnmatchedItems(id);
return ResponseEntity.ok(items);
}
}
@@ -0,0 +1,61 @@
package br.gov.sigefp.treasury.api;
import br.gov.sigefp.treasury.api.dto.CashAccountDTO;
import br.gov.sigefp.treasury.api.dto.CreateCashAccountDTO;
import br.gov.sigefp.treasury.service.CashAccountService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/treasury/cash-accounts")
@RequiredArgsConstructor
public class CashAccountController {
private final CashAccountService cashAccountService;
@PostMapping
public ResponseEntity<CashAccountDTO> create(@Valid @RequestBody CreateCashAccountDTO dto) {
CashAccountDTO created = cashAccountService.create(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@GetMapping("/{id}")
public ResponseEntity<CashAccountDTO> findById(@PathVariable UUID id) {
CashAccountDTO account = cashAccountService.findById(id);
return ResponseEntity.ok(account);
}
@GetMapping
public ResponseEntity<List<CashAccountDTO>> findAll(
@RequestParam(name = "type", required = false) String type,
@RequestParam(name = "activeOnly", required = false, defaultValue = "true") Boolean activeOnly) {
List<CashAccountDTO> accounts;
if (type != null) {
accounts = cashAccountService.findByType(type);
} else if (activeOnly) {
accounts = cashAccountService.findActive();
} else {
accounts = cashAccountService.findAll();
}
return ResponseEntity.ok(accounts);
}
@GetMapping("/{id}/available-balance")
public ResponseEntity<BigDecimal> getAvailableBalance(@PathVariable UUID id) {
BigDecimal balance = cashAccountService.getAvailableBalance(id);
return ResponseEntity.ok(balance);
}
@GetMapping("/{id}/current-balance")
public ResponseEntity<BigDecimal> getCurrentBalance(@PathVariable UUID id) {
BigDecimal balance = cashAccountService.getCurrentBalance(id);
return ResponseEntity.ok(balance);
}
}
@@ -0,0 +1,83 @@
package br.gov.sigefp.treasury.api;
import br.gov.sigefp.treasury.api.dto.CashFlowDTO;
import br.gov.sigefp.treasury.api.dto.CreateCashFlowDTO;
import br.gov.sigefp.treasury.service.CashFlowService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/treasury/cash-flow")
@RequiredArgsConstructor
public class CashFlowController {
private final CashFlowService cashFlowService;
@PostMapping
public ResponseEntity<CashFlowDTO> registerFlow(@Valid @RequestBody CreateCashFlowDTO dto) {
CashFlowDTO created = cashFlowService.registerFlow(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@GetMapping("/{id}")
public ResponseEntity<CashFlowDTO> findById(@PathVariable UUID id) {
CashFlowDTO cashFlow = cashFlowService.findById(id);
return ResponseEntity.ok(cashFlow);
}
@GetMapping
public ResponseEntity<Page<CashFlowDTO>> findByCashAccount(
@RequestParam UUID cashAccountId,
@RequestParam(required = false) LocalDate startDate,
@RequestParam(required = false) LocalDate endDate,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "transactionDate") String sortBy,
@RequestParam(defaultValue = "DESC") String sortDirection) {
Sort sort = Sort.by(Sort.Direction.fromString(sortDirection), sortBy);
Pageable pageable = PageRequest.of(page, size, sort);
Page<CashFlowDTO> result;
if (startDate != null && endDate != null) {
result = cashFlowService.findByCashAccountIdAndDateRange(
cashAccountId, startDate, endDate, pageable);
} else {
result = cashFlowService.findByCashAccountId(cashAccountId, pageable);
}
return ResponseEntity.ok(result);
}
@GetMapping("/{cashAccountId}/projected-balance")
public ResponseEntity<BigDecimal> calculateProjectedBalance(
@PathVariable UUID cashAccountId,
@RequestParam LocalDate targetDate) {
BigDecimal balance = cashFlowService.calculateProjectedBalance(cashAccountId, targetDate);
return ResponseEntity.ok(balance);
}
@GetMapping("/{cashAccountId}/summary")
public ResponseEntity<Map<String, BigDecimal>> getFlowSummary(
@PathVariable UUID cashAccountId,
@RequestParam LocalDate startDate,
@RequestParam LocalDate endDate) {
Map<String, BigDecimal> summary = cashFlowService.getFlowSummary(
cashAccountId, startDate, endDate);
return ResponseEntity.ok(summary);
}
}
@@ -0,0 +1,75 @@
package br.gov.sigefp.treasury.api;
import br.gov.sigefp.treasury.api.dto.ApprovePaymentDTO;
import br.gov.sigefp.treasury.api.dto.CreatePaymentAuthorizationDTO;
import br.gov.sigefp.treasury.api.dto.PaymentAuthorizationDTO;
import br.gov.sigefp.treasury.api.dto.RejectPaymentDTO;
import br.gov.sigefp.treasury.service.PaymentAuthorizationService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/treasury/authorizations")
@RequiredArgsConstructor
public class PaymentAuthorizationController {
private final PaymentAuthorizationService authorizationService;
@PostMapping
public ResponseEntity<PaymentAuthorizationDTO> requestAuthorization(
@Valid @RequestBody CreatePaymentAuthorizationDTO dto) {
PaymentAuthorizationDTO created = authorizationService.requestAuthorization(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@PostMapping("/{id}/approve")
public ResponseEntity<PaymentAuthorizationDTO> approve(
@PathVariable UUID id,
@Valid @RequestBody ApprovePaymentDTO dto) {
PaymentAuthorizationDTO updated = authorizationService.approve(
id, dto.getApproverId(), dto.getComments());
return ResponseEntity.ok(updated);
}
@PostMapping("/{id}/reject")
public ResponseEntity<PaymentAuthorizationDTO> reject(
@PathVariable UUID id,
@Valid @RequestBody RejectPaymentDTO dto) {
PaymentAuthorizationDTO updated = authorizationService.reject(
id, dto.getApproverId(), dto.getReason());
return ResponseEntity.ok(updated);
}
@GetMapping("/{id}")
public ResponseEntity<PaymentAuthorizationDTO> findById(@PathVariable UUID id) {
PaymentAuthorizationDTO authorization = authorizationService.findById(id);
return ResponseEntity.ok(authorization);
}
@GetMapping("/pending")
public ResponseEntity<List<PaymentAuthorizationDTO>> findPendingApprovals(
@RequestParam(name = "approverId") UUID approverId) {
List<PaymentAuthorizationDTO> authorizations = authorizationService.findPendingApprovals(approverId);
return ResponseEntity.ok(authorizations);
}
@GetMapping("/payment-order/{paymentOrderId}")
public ResponseEntity<List<PaymentAuthorizationDTO>> findByPaymentOrderId(
@PathVariable UUID paymentOrderId) {
List<PaymentAuthorizationDTO> authorizations = authorizationService.findByPaymentOrderId(paymentOrderId);
return ResponseEntity.ok(authorizations);
}
@GetMapping("/payment-batch/{paymentBatchId}")
public ResponseEntity<List<PaymentAuthorizationDTO>> findByPaymentBatchId(
@PathVariable UUID paymentBatchId) {
List<PaymentAuthorizationDTO> authorizations = authorizationService.findByPaymentBatchId(paymentBatchId);
return ResponseEntity.ok(authorizations);
}
}
@@ -0,0 +1,105 @@
package br.gov.sigefp.treasury.api;
import br.gov.sigefp.treasury.api.dto.CreatePaymentBatchDTO;
import br.gov.sigefp.treasury.api.dto.PaymentBatchDTO;
import br.gov.sigefp.treasury.api.dto.UpdateStatusDTO;
import br.gov.sigefp.treasury.service.PaymentBatchService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
/**
* Controller REST para gestão de lotes de pagamento.
*/
@RestController
@RequestMapping("/api/treasury/payment-batches")
@RequiredArgsConstructor
public class PaymentBatchController {
private final PaymentBatchService paymentBatchService;
@GetMapping
public ResponseEntity<?> findAll(
@RequestParam(name = "periodId", required = false) Long periodId,
@RequestParam(name = "ministryId", required = false) UUID ministryId,
@RequestParam(name = "status", required = false) String status,
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "20") int size,
@RequestParam(name = "sortBy", required = false) String sortBy,
@RequestParam(name = "sortDirection", required = false, defaultValue = "DESC") String sortDirection) {
// Se há filtros, retornar lista (sem paginação)
if (periodId != null || ministryId != null || status != null) {
List<PaymentBatchDTO> result;
if (periodId != null && ministryId != null) {
result = paymentBatchService.findByPeriodIdAndMinistryId(periodId, ministryId);
if (status != null) {
result = result.stream()
.filter(pb -> status.equals(pb.getStatus()))
.toList();
}
} else if (periodId != null && status != null) {
result = paymentBatchService.findByPeriodIdAndStatus(periodId, status);
} else if (ministryId != null && status != null) {
result = paymentBatchService.findByMinistryIdAndStatus(ministryId, status);
} else if (periodId != null) {
result = paymentBatchService.findByPeriodId(periodId);
} else if (ministryId != null) {
result = paymentBatchService.findByMinistryId(ministryId);
} else {
result = paymentBatchService.findByStatus(status);
}
return ResponseEntity.ok(result);
}
// Sem filtros, retornar paginado
Sort sort = sortBy != null
? Sort.by(Sort.Direction.fromString(sortDirection), sortBy)
: Sort.by(Sort.Direction.DESC, "createdAt");
Pageable pageable = PageRequest.of(page, size, sort);
Page<PaymentBatchDTO> result = paymentBatchService.findAll(pageable);
return ResponseEntity.ok(result);
}
@GetMapping("/{id}")
public ResponseEntity<PaymentBatchDTO> findById(@PathVariable UUID id) {
try {
PaymentBatchDTO dto = paymentBatchService.findById(id);
return ResponseEntity.ok(dto);
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@PostMapping
public ResponseEntity<PaymentBatchDTO> create(@Valid @RequestBody CreatePaymentBatchDTO dto) {
try {
PaymentBatchDTO created = paymentBatchService.create(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
@PostMapping("/{id}/status")
public ResponseEntity<PaymentBatchDTO> updateStatus(
@PathVariable UUID id,
@Valid @RequestBody UpdateStatusDTO dto) {
try {
PaymentBatchDTO updated = paymentBatchService.updateStatus(id, dto.getStatus());
return ResponseEntity.ok(updated);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
}
@@ -0,0 +1,113 @@
package br.gov.sigefp.treasury.api;
import br.gov.sigefp.treasury.api.dto.CreatePaymentOrderDTO;
import br.gov.sigefp.treasury.api.dto.GenerateOrdersFromPayrollRunDTO;
import br.gov.sigefp.treasury.api.dto.PaymentOrderDTO;
import br.gov.sigefp.treasury.api.dto.UpdateStatusDTO;
import br.gov.sigefp.treasury.service.PaymentOrderService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
/**
* Controller REST para gestão de ordens de pagamento.
*/
@RestController
@RequestMapping("/api/treasury/payment-orders")
@RequiredArgsConstructor
public class PaymentOrderController {
private final PaymentOrderService paymentOrderService;
@GetMapping
public ResponseEntity<?> findAll(
@RequestParam(name = "batchId", required = false) UUID batchId,
@RequestParam(name = "status", required = false) String status,
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "20") int size,
@RequestParam(name = "sortBy", required = false) String sortBy,
@RequestParam(name = "sortDirection", required = false, defaultValue = "DESC") String sortDirection) {
// Se há filtros, retornar lista (sem paginação)
if (batchId != null || status != null) {
List<PaymentOrderDTO> result;
if (batchId != null && status != null) {
result = paymentOrderService.findByPaymentBatchIdAndStatus(batchId, status);
} else if (batchId != null) {
result = paymentOrderService.findByPaymentBatchId(batchId);
} else {
result = paymentOrderService.findByStatus(status);
}
return ResponseEntity.ok(result);
}
// Sem filtros, retornar paginado
Sort sort = sortBy != null
? Sort.by(Sort.Direction.fromString(sortDirection), sortBy)
: Sort.by(Sort.Direction.DESC, "createdAt");
Pageable pageable = PageRequest.of(page, size, sort);
Page<PaymentOrderDTO> result = paymentOrderService.findAll(pageable);
return ResponseEntity.ok(result);
}
@GetMapping("/{id}")
public ResponseEntity<PaymentOrderDTO> findById(@PathVariable UUID id) {
try {
PaymentOrderDTO dto = paymentOrderService.findById(id);
return ResponseEntity.ok(dto);
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@PostMapping
public ResponseEntity<PaymentOrderDTO> create(@Valid @RequestBody CreatePaymentOrderDTO dto) {
try {
PaymentOrderDTO created = paymentOrderService.create(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
@PostMapping("/{id}/status")
public ResponseEntity<PaymentOrderDTO> updateStatus(
@PathVariable UUID id,
@Valid @RequestBody UpdateStatusDTO dto) {
try {
PaymentOrderDTO updated = paymentOrderService.updateStatus(id, dto.getStatus());
return ResponseEntity.ok(updated);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
/**
* Gerar ordens de pagamento a partir de uma execução de folha.
* POST /api/treasury/payment-orders/generate-from-payroll
*/
@PostMapping("/generate-from-payroll")
public ResponseEntity<List<PaymentOrderDTO>> generateFromPayrollRun(
@Valid @RequestBody GenerateOrdersFromPayrollRunDTO dto) {
try {
List<PaymentOrderDTO> orders = paymentOrderService.generateOrdersInternal(
dto.getPayrollRunId(),
dto.getPaymentBatchId());
return ResponseEntity.status(HttpStatus.CREATED).body(orders);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
} catch (RuntimeException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}
@@ -0,0 +1,73 @@
package br.gov.sigefp.treasury.api;
import br.gov.sigefp.treasury.api.dto.CreateTreasuryEntryDTO;
import br.gov.sigefp.treasury.api.dto.TreasuryEntryDTO;
import br.gov.sigefp.treasury.domain.TreasuryEntryType;
import br.gov.sigefp.treasury.service.TreasuryEntryService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.UUID;
@RestController
@RequestMapping("/api/treasury/entries")
@RequiredArgsConstructor
public class TreasuryEntryController {
private final TreasuryEntryService treasuryEntryService;
@PostMapping
public ResponseEntity<TreasuryEntryDTO> create(@Valid @RequestBody CreateTreasuryEntryDTO dto) {
TreasuryEntryDTO created = treasuryEntryService.create(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@GetMapping("/{id}")
public ResponseEntity<TreasuryEntryDTO> findById(@PathVariable UUID id) {
TreasuryEntryDTO entry = treasuryEntryService.findById(id);
return ResponseEntity.ok(entry);
}
@GetMapping
public ResponseEntity<Page<TreasuryEntryDTO>> findAll(
@RequestParam(required = false) UUID cashAccountId,
@RequestParam(required = false) TreasuryEntryType type,
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "transactionDate") String sortBy,
@RequestParam(defaultValue = "DESC") String sortDirection) {
Sort sort = Sort.by(Sort.Direction.fromString(sortDirection), sortBy);
Pageable pageable = PageRequest.of(page, size, sort);
Page<TreasuryEntryDTO> result;
if (cashAccountId != null) {
result = treasuryEntryService.findByCashAccountId(cashAccountId, pageable);
} else if (type != null) {
result = treasuryEntryService.findByType(type, pageable);
} else if (status != null) {
result = treasuryEntryService.findByStatus(status, pageable);
} else {
// Retornar vazio se nenhum filtro for fornecido
result = Page.empty(pageable);
}
return ResponseEntity.ok(result);
}
@GetMapping("/{cashAccountId}/available-balance")
public ResponseEntity<BigDecimal> getAvailableBalance(@PathVariable UUID cashAccountId) {
BigDecimal balance = treasuryEntryService.calculateAvailableBalance(cashAccountId);
return ResponseEntity.ok(balance);
}
}
@@ -0,0 +1,70 @@
package br.gov.sigefp.treasury.api;
import br.gov.sigefp.treasury.api.dto.CreateTreasuryPaymentDTO;
import br.gov.sigefp.treasury.api.dto.TreasuryPaymentDTO;
import br.gov.sigefp.treasury.service.TreasuryPaymentService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
/**
* Controller REST para gestão de pagamentos efetivados pela tesouraria.
*/
@RestController
@RequestMapping("/api/treasury/payments")
@RequiredArgsConstructor
public class TreasuryPaymentController {
private final TreasuryPaymentService treasuryPaymentService;
@GetMapping
public ResponseEntity<?> findAll(
@RequestParam(name = "paymentOrderId", required = false) UUID paymentOrderId,
@RequestParam(name = "status", required = false) String status,
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "20") int size,
@RequestParam(name = "sortBy", required = false) String sortBy,
@RequestParam(name = "sortDirection", required = false, defaultValue = "DESC") String sortDirection) {
// Se há filtros, retornar lista (sem paginação)
if (paymentOrderId != null || status != null) {
List<TreasuryPaymentDTO> result;
if (paymentOrderId != null && status != null) {
result = treasuryPaymentService.findByPaymentOrderIdAndStatus(paymentOrderId, status);
} else if (paymentOrderId != null) {
result = treasuryPaymentService.findByPaymentOrderId(paymentOrderId);
} else {
result = treasuryPaymentService.findByStatus(status);
}
return ResponseEntity.ok(result);
}
// Sem filtros, retornar paginado
Sort sort = sortBy != null
? Sort.by(Sort.Direction.fromString(sortDirection), sortBy)
: Sort.by(Sort.Direction.DESC, "paidAt");
Pageable pageable = PageRequest.of(page, size, sort);
Page<TreasuryPaymentDTO> result = treasuryPaymentService.findAll(pageable);
return ResponseEntity.ok(result);
}
@PostMapping
public ResponseEntity<TreasuryPaymentDTO> registerPayment(@Valid @RequestBody CreateTreasuryPaymentDTO dto) {
try {
TreasuryPaymentDTO created = treasuryPaymentService.registerPayment(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
}
@@ -0,0 +1,97 @@
package br.gov.sigefp.treasury.api;
import br.gov.sigefp.common.exception.ResourceNotFoundException;
import br.gov.sigefp.treasury.api.dto.CreateTreasuryPlanDTO;
import br.gov.sigefp.treasury.api.dto.TreasuryPlanDTO;
import br.gov.sigefp.treasury.service.TreasuryPlanService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.List;
import java.util.UUID;
/**
* Controller REST para gestão de Planos de Tesouraria.
* Conforme Master Plan - Module 2: Preventive Control.
*/
@RestController
@RequestMapping("/api/treasury/plans")
@RequiredArgsConstructor
@Slf4j
public class TreasuryPlanController {
private final TreasuryPlanService treasuryPlanService;
@PostMapping
public ResponseEntity<TreasuryPlanDTO> create(@Valid @RequestBody CreateTreasuryPlanDTO dto) {
TreasuryPlanDTO created = treasuryPlanService.createPlan(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@GetMapping("/{id}")
public ResponseEntity<TreasuryPlanDTO> findById(@PathVariable UUID id) {
try {
TreasuryPlanDTO plan = treasuryPlanService.findById(id);
return ResponseEntity.ok(plan);
} catch (ResourceNotFoundException e) {
log.warn("Plano não encontrado: {}", id);
return ResponseEntity.notFound().build();
}
}
@GetMapping("/status/{status}")
public ResponseEntity<List<TreasuryPlanDTO>> findByStatus(@PathVariable String status) {
List<TreasuryPlanDTO> plans = treasuryPlanService.findByStatus(status);
return ResponseEntity.ok(plans);
}
@GetMapping("/active")
public ResponseEntity<TreasuryPlanDTO> findActivePlan(
@RequestParam(required = false) String date) {
try {
LocalDate searchDate;
if (date != null) {
try {
searchDate = LocalDate.parse(date);
} catch (DateTimeParseException e) {
log.warn("Data inválida fornecida: {}", date);
return ResponseEntity.badRequest().build();
}
} else {
searchDate = LocalDate.now();
}
TreasuryPlanDTO plan = treasuryPlanService.findActivePlanForDate(searchDate);
if (plan == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(plan);
} catch (Exception e) {
log.error("Erro ao buscar plano ativo", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@PutMapping("/{id}/approve")
public ResponseEntity<TreasuryPlanDTO> approve(
@PathVariable UUID id,
@RequestParam UUID approverId) {
try {
TreasuryPlanDTO approved = treasuryPlanService.approvePlan(id, approverId);
return ResponseEntity.ok(approved);
} catch (ResourceNotFoundException e) {
log.warn("Plano não encontrado para aprovação: {}", id);
return ResponseEntity.notFound().build();
} catch (Exception e) {
log.error("Erro ao aprovar plano: {}", id, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}
@@ -0,0 +1,27 @@
package br.gov.sigefp.treasury.api.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* DTO para transferência de dados de aprovação individual.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ApprovalDTO {
private UUID id;
private UUID authorizationId;
private Integer level;
private UUID approvedBy;
private LocalDateTime approvedAt;
private String comments;
private String signatureHash;
}
@@ -0,0 +1,25 @@
package br.gov.sigefp.treasury.api.dto;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.UUID;
/**
* DTO para aprovação de pagamento.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ApprovePaymentDTO {
@NotNull(message = "ID do aprovador é obrigatório")
private UUID approverId;
private String comments;
}
@@ -0,0 +1,36 @@
package br.gov.sigefp.treasury.api.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
/**
* DTO para transferência de dados de conciliação bancária.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BankReconciliationDTO {
private UUID id;
private UUID cashAccountId;
private LocalDate reconciliationDate;
private BigDecimal statementBalance;
private BigDecimal systemBalance;
private BigDecimal difference;
private String status; // PENDING, RECONCILED, DISCREPANCY
private UUID reconciledBy;
private LocalDateTime reconciledAt;
private List<ReconciliationItemDTO> items;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,44 @@
package br.gov.sigefp.treasury.api.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* DTO para transferência de dados de conta de caixa/bancária.
* Inclui novos campos conforme Master Plan: parentId, category, iban, swiftCode.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CashAccountDTO {
private UUID id;
private String code;
private String name;
private String type; // CASH, BANK_ACCOUNT
private UUID bankId;
private String accountNumber;
private String branchCode;
// Novos campos conforme Master Plan
private String iban; // IBAN da conta (ISO format)
private String swiftCode; // Código SWIFT
private UUID parentId; // Hierarquia CUT - Conta pai
private String category; // CENTRAL_CUT, SUB_ACCOUNT, TRANSIT, REVENUE
private BigDecimal overdraftLimit; // Limite de descoberto autorizado
private String currency;
private Boolean isActive;
private BigDecimal currentBalance;
private BigDecimal availableBalance;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,33 @@
package br.gov.sigefp.treasury.api.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* DTO para transferência de dados de fluxo de caixa.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CashFlowDTO {
private UUID id;
private UUID cashAccountId;
private LocalDate transactionDate;
private String type; // INFLOW, OUTFLOW
private BigDecimal amount;
private String description;
private UUID referenceId;
private String referenceType; // PAYMENT_ORDER, TREASURY_ENTRY, etc.
private BigDecimal balanceAfter;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,36 @@
package br.gov.sigefp.treasury.api.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotEmpty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* DTO para criação de conciliação bancária.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreateBankReconciliationDTO {
@NotNull(message = "Conta bancária é obrigatória")
private UUID cashAccountId;
@NotNull(message = "Data de conciliação é obrigatória")
private LocalDate reconciliationDate;
@NotNull(message = "Saldo do extrato é obrigatório")
private BigDecimal statementBalance;
@NotEmpty(message = "Itens de conciliação são obrigatórios")
private List<ReconciliationItemDTO> reconciliationItems;
}
@@ -0,0 +1,64 @@
package br.gov.sigefp.treasury.api.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.util.UUID;
/**
* DTO para criação de conta de caixa/bancária.
* Inclui novos campos conforme Master Plan: parentId, category, iban, swiftCode.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreateCashAccountDTO {
@NotBlank(message = "Código é obrigatório")
@Size(max = 50, message = "Código deve ter no máximo 50 caracteres")
private String code;
@NotBlank(message = "Nome é obrigatório")
@Size(max = 200, message = "Nome deve ter no máximo 200 caracteres")
private String name;
@NotBlank(message = "Tipo é obrigatório")
private String type; // CASH, BANK_ACCOUNT
@NotNull(message = "Unidade Orgânica é obrigatória")
private UUID orgUnitId;
private UUID bankId; // Obrigatório se type = BANK_ACCOUNT
@Size(max = 50, message = "Número da conta deve ter no máximo 50 caracteres")
private String accountNumber;
@Size(max = 20, message = "Código da agência deve ter no máximo 20 caracteres")
private String branchCode;
// Novos campos conforme Master Plan
@Size(max = 34, message = "IBAN deve ter no máximo 34 caracteres")
private String iban; // IBAN da conta (ISO format)
@Size(max = 11, message = "Código SWIFT deve ter no máximo 11 caracteres")
private String swiftCode; // Código SWIFT
private UUID parentId; // Hierarquia CUT - Conta pai (self-reference)
private String category; // CENTRAL_CUT, SUB_ACCOUNT, TRANSIT, REVENUE
private BigDecimal overdraftLimit; // Limite de descoberto autorizado
@Size(max = 3, message = "Moeda deve ter no máximo 3 caracteres")
private String currency; // Default: XOF
private Boolean isActive;
}
@@ -0,0 +1,42 @@
package br.gov.sigefp.treasury.api.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
/**
* DTO para criação de fluxo de caixa.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreateCashFlowDTO {
@NotNull(message = "Conta de caixa é obrigatória")
private UUID cashAccountId;
@NotNull(message = "Data da transação é obrigatória")
private LocalDate transactionDate;
@NotNull(message = "Tipo é obrigatório")
private String type; // INFLOW, OUTFLOW
@NotNull(message = "Valor é obrigatório")
@Positive(message = "Valor deve ser positivo")
private BigDecimal amount;
private String description;
private UUID referenceId;
private String referenceType;
}
@@ -0,0 +1,31 @@
package br.gov.sigefp.treasury.api.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Min;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.UUID;
/**
* DTO para criação de autorização de pagamento.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreatePaymentAuthorizationDTO {
private UUID paymentOrderId; // Um dos dois deve ser fornecido
private UUID paymentBatchId; // Um dos dois deve ser fornecido
@NotNull(message = "Usuário solicitante é obrigatório")
private UUID requestedBy;
@NotNull(message = "Nível de aprovação necessário é obrigatório")
@Min(value = 1, message = "Nível de aprovação deve ser pelo menos 1")
private Integer requiredApprovalLevel;
}
@@ -0,0 +1,26 @@
package br.gov.sigefp.treasury.api.dto;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.UUID;
/**
* DTO para criação de lote de pagamento.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreatePaymentBatchDTO {
@NotNull(message = "ID do período é obrigatório")
private Long periodId;
@NotNull(message = "ID do ministério é obrigatório")
private UUID ministryId;
}
@@ -0,0 +1,44 @@
package br.gov.sigefp.treasury.api.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.util.UUID;
/**
* DTO para criação de ordem de pagamento.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreatePaymentOrderDTO {
@NotNull(message = "ID do lote de pagamento é obrigatório")
private UUID paymentBatchId;
private UUID payrollRunId;
@NotNull(message = "ID do agente é obrigatório")
private UUID agentId;
@NotNull(message = "ID da conta bancária é obrigatório")
private UUID bankAccountId;
@NotNull(message = "Valor bruto é obrigatório")
@Positive(message = "Valor bruto deve ser positivo")
private BigDecimal grossAmount;
@NotNull(message = "Valor líquido é obrigatório")
@Positive(message = "Valor líquido deve ser positivo")
private BigDecimal netAmount;
private BigDecimal taxAmount;
private String taxRetentionType;
private UUID taxCollectionAccountId;
}
@@ -0,0 +1,51 @@
package br.gov.sigefp.treasury.api.dto;
import br.gov.sigefp.treasury.domain.TreasuryEntryType;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
/**
* DTO para criação de entrada de tesouraria.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreateTreasuryEntryDTO {
@NotNull(message = "Tipo é obrigatório")
private TreasuryEntryType type;
@NotNull(message = "Valor é obrigatório")
@Positive(message = "Valor deve ser positivo")
private BigDecimal amount;
@NotNull(message = "Data da transação é obrigatória")
private LocalDate transactionDate;
@NotNull(message = "Referência do documento é obrigatória")
private String documentReference;
private String description;
private String status; // DRAFT, PENDING_APPROVAL
private Integer approvalLevel;
private UUID cashAccountId;
private UUID paymentOrderId;
private UUID paymentBatchId;
private UUID budgetLineId;
}
@@ -0,0 +1,37 @@
package br.gov.sigefp.treasury.api.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
import java.util.UUID;
/**
* DTO para criação de pagamento efetivado.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreateTreasuryPaymentDTO {
@NotNull(message = "ID da ordem de pagamento é obrigatório")
private UUID paymentOrderId;
private Instant paidAt;
@Size(max = 100, message = "Referência da transação deve ter no máximo 100 caracteres")
private String transactionRef;
@NotBlank(message = "Status é obrigatório")
private String status; // PENDING, PAID, REJECTED, CANCELLED
@Size(max = 1000, message = "Mensagem deve ter no máximo 1000 caracteres")
private String message;
}
@@ -0,0 +1,40 @@
package br.gov.sigefp.treasury.api.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Max;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* DTO para criação de Plano de Tesouraria.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreateTreasuryPlanDTO {
@NotNull(message = "Ano fiscal é obrigatório")
@Min(value = 2020, message = "Ano fiscal deve ser a partir de 2020")
private Integer fiscalYear;
@NotNull(message = "Mês de referência é obrigatório")
@Min(value = 1, message = "Mês deve ser entre 1 e 12")
@Max(value = 12, message = "Mês deve ser entre 1 e 12")
private Integer referenceMonth;
@NotNull(message = "Teto aprovado é obrigatório")
@Positive(message = "Teto aprovado deve ser positivo")
private BigDecimal approvedCeiling;
private LocalDate startDate; // Opcional - calculado automaticamente se não fornecido
private LocalDate endDate; // Opcional - calculado automaticamente se não fornecido
}
@@ -0,0 +1,26 @@
package br.gov.sigefp.treasury.api.dto;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.UUID;
/**
* DTO para gerar ordens de pagamento a partir de uma execução de folha.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GenerateOrdersFromPayrollRunDTO {
@NotNull(message = "ID da execução de folha é obrigatório")
private UUID payrollRunId;
@NotNull(message = "ID do lote de pagamento é obrigatório")
private UUID paymentBatchId;
}
@@ -0,0 +1,23 @@
package br.gov.sigefp.treasury.api.dto;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.UUID;
/**
* DTO para parear item de conciliação com transação do sistema.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MatchReconciliationItemDTO {
@NotNull(message = "ID da transação do sistema é obrigatório")
private UUID systemTransactionId;
}
@@ -0,0 +1,34 @@
package br.gov.sigefp.treasury.api.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
/**
* DTO para transferência de dados de autorização de pagamento.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PaymentAuthorizationDTO {
private UUID id;
private UUID paymentOrderId;
private UUID paymentBatchId;
private UUID requestedBy;
private LocalDateTime requestedAt;
private Integer requiredApprovalLevel;
private Integer currentApprovalLevel;
private String status; // PENDING, PARTIALLY_APPROVED, APPROVED, REJECTED
private String rejectionReason;
private List<ApprovalDTO> approvals;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,37 @@
package br.gov.sigefp.treasury.api.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* DTO para transferência de dados de lote de pagamento.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PaymentBatchDTO {
private UUID id;
@NotNull(message = "ID do período é obrigatório")
private Long periodId;
@NotNull(message = "ID do ministério é obrigatório")
private UUID ministryId;
private LocalDateTime createdAt;
private UUID createdBy;
@NotBlank(message = "Status é obrigatório")
private String status; // CREATED, SENT_TO_BANK, CONFIRMED, REJECTED
}
@@ -0,0 +1,47 @@
package br.gov.sigefp.treasury.api.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.util.UUID;
/**
* DTO para transferência de dados de ordem de pagamento.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PaymentOrderDTO {
private UUID id;
private UUID paymentBatchId;
private UUID payrollRunId;
private UUID agentId;
private UUID bankAccountId;
private UUID budgetLineId; // Referência à linha orçamentária
@NotNull(message = "Valor bruto é obrigatório")
@Positive(message = "Valor bruto deve ser positivo")
private BigDecimal grossAmount;
@NotNull(message = "Valor líquido é obrigatório")
@Positive(message = "Valor líquido deve ser positivo")
private BigDecimal netAmount;
private BigDecimal taxAmount;
private String taxRetentionType;
private UUID taxCollectionAccountId;
private String status; // CREATED, SENT_TO_BANK, PAID, REJECTED
}
@@ -0,0 +1,29 @@
package br.gov.sigefp.treasury.api.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
/**
* DTO para transferência de dados de item de conciliação.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReconciliationItemDTO {
private UUID id;
private UUID reconciliationId;
private LocalDate transactionDate;
private String description;
private BigDecimal statementAmount;
private BigDecimal systemAmount;
private String matchStatus; // MATCHED, UNMATCHED, PENDING
private UUID matchedTransactionId;
}
@@ -0,0 +1,27 @@
package br.gov.sigefp.treasury.api.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.UUID;
/**
* DTO para rejeição de pagamento.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RejectPaymentDTO {
@NotNull(message = "ID do aprovador é obrigatório")
private UUID approverId;
@NotBlank(message = "Motivo da rejeição é obrigatório")
private String reason;
}
@@ -0,0 +1,40 @@
package br.gov.sigefp.treasury.api.dto;
import br.gov.sigefp.treasury.domain.TreasuryEntryType;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* DTO para transferência de dados de entrada de tesouraria.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TreasuryEntryDTO {
private UUID id;
private TreasuryEntryType type;
private BigDecimal amount;
private LocalDate transactionDate;
private String documentReference;
private String description;
private String status; // DRAFT, PENDING_APPROVAL, APPROVED, REJECTED, EXECUTED
private Integer approvalLevel;
private UUID approvedBy;
private LocalDateTime approvedAt;
private UUID cashAccountId;
private UUID paymentOrderId;
private UUID paymentBatchId;
private UUID budgetLineId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,39 @@
package br.gov.sigefp.treasury.api.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
import java.util.UUID;
/**
* DTO para transferência de dados de pagamento efetivado pela tesouraria.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TreasuryPaymentDTO {
private UUID id;
@NotNull(message = "ID da ordem de pagamento é obrigatório")
private UUID paymentOrderId;
private Instant paidAt;
@Size(max = 100, message = "Referência da transação deve ter no máximo 100 caracteres")
private String transactionRef;
@NotBlank(message = "Status é obrigatório")
private String status; // PENDING, PAID, REJECTED, CANCELLED
@Size(max = 1000, message = "Mensagem deve ter no máximo 1000 caracteres")
private String message;
}
@@ -0,0 +1,36 @@
package br.gov.sigefp.treasury.api.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* DTO para transferência de dados de Plano de Tesouraria.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TreasuryPlanDTO {
private UUID id;
private Integer fiscalYear;
private Integer referenceMonth; // 1-12
private String status; // DRAFT, APPROVED, CLOSED
private BigDecimal approvedCeiling;
private BigDecimal executedAmount; // Calculado dinamicamente
private BigDecimal availableAmount; // approvedCeiling - executedAmount
private LocalDate startDate;
private LocalDate endDate;
private UUID approvedBy;
private LocalDateTime approvedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,38 @@
package br.gov.sigefp.treasury.api.dto;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.util.UUID;
/**
* DTO para atualização de conta de caixa/bancária.
* Inclui novos campos conforme Master Plan: parentId, category, iban, swiftCode.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UpdateCashAccountDTO {
private String name;
@Size(max = 34, message = "IBAN deve ter no máximo 34 caracteres")
private String iban;
@Size(max = 11, message = "Código SWIFT deve ter no máximo 11 caracteres")
private String swiftCode;
private UUID parentId; // Hierarquia CUT - Conta pai
private String category; // CENTRAL_CUT, SUB_ACCOUNT, TRANSIT, REVENUE
private Boolean isActive;
private BigDecimal overdraftLimit; // Limite de descoberto autorizado
}
@@ -0,0 +1,21 @@
package br.gov.sigefp.treasury.api.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO para atualização de status.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UpdateStatusDTO {
@NotBlank(message = "Status é obrigatório")
private String status;
}
@@ -0,0 +1,48 @@
package br.gov.sigefp.treasury.domain;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Entidade que representa uma aprovação individual em um workflow hierárquico.
* Parte do histórico de aprovações de PaymentAuthorization.
*/
@Entity
@Table(name = "approval", indexes = {
@Index(name = "idx_approval_authorization", columnList = "authorization_id"),
@Index(name = "idx_approval_approver", columnList = "approved_by"),
@Index(name = "idx_approval_level", columnList = "level")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Approval {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "authorization_id", nullable = false)
private PaymentAuthorization authorization;
@Column(nullable = false)
private Integer level; // Nível da aprovação (1, 2, 3...)
@Column(name = "approved_by", nullable = false)
private UUID approvedBy; // Usuário que aprovou
@Column(name = "approved_at", nullable = false)
private LocalDateTime approvedAt;
@Column(columnDefinition = "TEXT")
private String comments;
@Column(name = "signature_hash", length = 255)
private String signatureHash; // Hash da assinatura digital (futuro)
}
@@ -0,0 +1,61 @@
package br.gov.sigefp.treasury.domain;
import br.gov.sigefp.common.domain.BaseEntity;
import jakarta.persistence.*;
import lombok.*;
import lombok.experimental.SuperBuilder;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* Entidade que representa uma conciliação bancária.
* Compara saldos e transações do sistema com extratos bancários.
*/
@Entity
@Table(name = "bank_reconciliation", indexes = {
@Index(name = "idx_reconciliation_cash_account", columnList = "cash_account_id"),
@Index(name = "idx_reconciliation_date", columnList = "reconciliation_date"),
@Index(name = "idx_reconciliation_status", columnList = "status")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder
public class BankReconciliation extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "cash_account_id", nullable = false)
private CashAccount cashAccount;
@Column(name = "reconciliation_date", nullable = false)
private LocalDate reconciliationDate;
@Column(name = "statement_balance", nullable = false, precision = 19, scale = 2)
private BigDecimal statementBalance; // Saldo do extrato bancário
@Column(name = "system_balance", nullable = false, precision = 19, scale = 2)
private BigDecimal systemBalance; // Saldo do sistema
@Column(precision = 19, scale = 2)
private BigDecimal difference; // Diferença (statementBalance - systemBalance)
@Column(nullable = false, length = 20)
@Builder.Default
private String status = "PENDING"; // PENDING, RECONCILED, DISCREPANCY
@Column(name = "reconciled_by")
private UUID reconciledBy; // Usuário que conciliou
@Column(name = "reconciled_at")
private LocalDateTime reconciledAt;
@OneToMany(mappedBy = "reconciliation", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<ReconciliationItem> reconciliationItems = new ArrayList<>();
}
@@ -0,0 +1,79 @@
package br.gov.sigefp.treasury.domain;
import br.gov.sigefp.common.domain.BaseEntity;
import jakarta.persistence.*;
import lombok.*;
import lombok.experimental.SuperBuilder;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
/**
* Entidade que representa uma conta de caixa ou bancária do Tesouro.
* Gerencia disponibilidades e saldos.
*/
@Entity
@Table(name = "cash_account", indexes = {
@Index(name = "idx_cash_account_code", columnList = "code"),
@Index(name = "idx_cash_account_type", columnList = "type"),
@Index(name = "idx_cash_account_active", columnList = "is_active")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder
public class CashAccount extends BaseEntity {
@Column(nullable = false, unique = true, length = 50)
private String code; // Ex: "CAIXA-001", "BANCO-BCEAO-001"
@Column(nullable = false, length = 200)
private String name; // Ex: "Caixa Principal", "Conta BCEAO - Tesouro"
@Column(nullable = false, length = 20)
private String type; // CASH, BANK_ACCOUNT
@Column(name = "bank_id")
private java.util.UUID bankId; // Se tipo = BANK_ACCOUNT
@Column(name = "parent_id")
private java.util.UUID parentId; // Conta Pai (Hierarquia CUT)
@Column(name = "category", length = 20)
private String category; // TRANSIT, CENTRAL_CUT, SUB_ACCOUNT
@Column(name = "account_number", length = 50)
private String accountNumber;
@Column(name = "branch_code", length = 20)
private String branchCode;
@Column(nullable = false, length = 3)
@Builder.Default
private String currency = "XOF";
@Column(name = "org_unit_id", nullable = true)
private java.util.UUID orgUnitId; // Vínculo com Unidade Orgânica (Ministério/Direção)
@Column(name = "is_active", nullable = false)
@Builder.Default
private Boolean isActive = true;
@Column(name = "current_balance", nullable = false, precision = 19, scale = 2)
@Builder.Default
private BigDecimal currentBalance = BigDecimal.ZERO;
@Column(name = "available_balance", nullable = false, precision = 19, scale = 2)
@Builder.Default
private BigDecimal availableBalance = BigDecimal.ZERO; // Saldo disponível (após compromissos)
@OneToMany(mappedBy = "cashAccount", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<TreasuryEntry> treasuryEntries = new ArrayList<>();
@OneToMany(mappedBy = "cashAccount", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<CashFlow> cashFlows = new ArrayList<>();
}
@@ -0,0 +1,54 @@
package br.gov.sigefp.treasury.domain;
import br.gov.sigefp.common.domain.BaseEntity;
import jakarta.persistence.*;
import lombok.*;
import lombok.experimental.SuperBuilder;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
/**
* Entidade que representa uma transação de fluxo de caixa.
* Rastreia entradas e saídas de caixa.
*/
@Entity
@Table(name = "cash_flow", indexes = {
@Index(name = "idx_cash_flow_account", columnList = "cash_account_id"),
@Index(name = "idx_cash_flow_date", columnList = "transaction_date"),
@Index(name = "idx_cash_flow_type", columnList = "type"),
@Index(name = "idx_cash_flow_reference", columnList = "reference_type,reference_id")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder
public class CashFlow extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "cash_account_id", nullable = false)
private CashAccount cashAccount;
@Column(name = "transaction_date", nullable = false)
private LocalDate transactionDate;
@Column(nullable = false, length = 20)
private String type; // INFLOW, OUTFLOW
@Column(nullable = false, precision = 19, scale = 2)
private BigDecimal amount;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "reference_id")
private UUID referenceId; // ID da entidade relacionada
@Column(name = "reference_type", length = 50)
private String referenceType; // PAYMENT_ORDER, TREASURY_ENTRY, etc.
@Column(name = "balance_after", nullable = false, precision = 19, scale = 2)
private BigDecimal balanceAfter; // Saldo após a transação
}
@@ -0,0 +1,64 @@
package br.gov.sigefp.treasury.domain;
import br.gov.sigefp.common.domain.AuditableEntity;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
/**
* Entidade que representa um pagamento.
* Usa LocalDate para todas as datas relacionadas.
*/
@Entity
@Table(name = "payments", indexes = {
@Index(name = "idx_payment_number", columnList = "payment_number"),
@Index(name = "idx_payment_date", columnList = "payment_date"),
@Index(name = "idx_payment_status", columnList = "status")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Payment extends AuditableEntity {
@Column(nullable = false, unique = true, length = 50)
private String paymentNumber;
@Column(name = "budget_execution_id")
private UUID budgetExecutionId; // Referência à execução orçamentária
@Column(nullable = false)
private LocalDate paymentDate;
@Column(nullable = false)
private LocalDate dueDate;
private LocalDate paidDate;
@Column(nullable = false, precision = 19, scale = 2)
private BigDecimal amount;
@Column(nullable = false, length = 50)
@Builder.Default
private String status = "PENDING"; // PENDING, APPROVED, PAID, CANCELLED
@Column(nullable = false, length = 200)
private String beneficiary;
@Column(length = 50)
private String beneficiaryDocument; // NIF, CNPJ, etc.
@Column(length = 500)
private String description;
@Column(length = 100)
private String paymentMethod; // BANK_TRANSFER, CHECK, CASH, etc.
@Column(length = 500)
private String notes;
}
@@ -0,0 +1,60 @@
package br.gov.sigefp.treasury.domain;
import br.gov.sigefp.common.domain.BaseEntity;
import jakarta.persistence.*;
import lombok.*;
import lombok.experimental.SuperBuilder;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* Entidade que representa um workflow de aprovação hierárquica de pagamentos.
* Gerencia o processo de autorização de pagamentos por níveis.
*/
@Entity
@Table(name = "payment_authorization", indexes = {
@Index(name = "idx_auth_payment_order", columnList = "payment_order_id"),
@Index(name = "idx_auth_payment_batch", columnList = "payment_batch_id"),
@Index(name = "idx_auth_status", columnList = "status"),
@Index(name = "idx_auth_requested_by", columnList = "requested_by")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder
public class PaymentAuthorization extends BaseEntity {
@Column(name = "payment_order_id")
private UUID paymentOrderId; // Ordem de pagamento individual
@Column(name = "payment_batch_id")
private UUID paymentBatchId; // Lote de pagamento
@Column(name = "requested_by", nullable = false)
private UUID requestedBy; // Usuário que solicitou
@Column(name = "requested_at", nullable = false)
private LocalDateTime requestedAt;
@Column(name = "required_approval_level", nullable = false)
private Integer requiredApprovalLevel; // Nível necessário (1, 2, 3...)
@Column(name = "current_approval_level", nullable = false)
@Builder.Default
private Integer currentApprovalLevel = 1; // Nível atual
@Column(nullable = false, length = 20)
@Builder.Default
private String status = "PENDING"; // PENDING, PARTIALLY_APPROVED, APPROVED, REJECTED
@Column(name = "rejection_reason", columnDefinition = "TEXT")
private String rejectionReason;
@OneToMany(mappedBy = "authorization", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<Approval> approvals = new ArrayList<>(); // Histórico de aprovações
}
@@ -0,0 +1,52 @@
package br.gov.sigefp.treasury.domain;
import br.gov.sigefp.common.domain.BaseEntity;
import jakarta.persistence.*;
import lombok.*;
import lombok.experimental.SuperBuilder;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* Entidade que representa um lote de pagamentos.
* Usa IDs para referências a outros módulos.
*/
@Entity
@Table(name = "payment_batch", indexes = {
@Index(name = "idx_payment_batch_period", columnList = "period_id"),
@Index(name = "idx_payment_batch_ministry", columnList = "ministry_id"),
@Index(name = "idx_payment_batch_status", columnList = "status"),
@Index(name = "idx_payment_batch_created", columnList = "created_at")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder
public class PaymentBatch extends BaseEntity {
@Column(name = "period_id")
private Long periodId; // Referência ao período de folha (rh.payroll_period)
@Column(name = "ministry_id")
private UUID ministryId; // Referência ao ministério (org.ministry)
@Column(name = "created_by")
private UUID createdBy; // ID do usuário que criou o lote
@Column(nullable = false, length = 20)
@Builder.Default
private String status = "CREATED"; // CREATED, PROCESSING, SENT_TO_BANK, COMPLETED, REJECTED - preparado para enum
@Column(length = 255)
private String reference;
@Column(columnDefinition = "TEXT")
private String description;
@OneToMany(mappedBy = "paymentBatch", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<PaymentOrder> paymentOrders = new ArrayList<>();
}
@@ -0,0 +1,72 @@
package br.gov.sigefp.treasury.domain;
import br.gov.sigefp.common.domain.BaseEntity;
import jakarta.persistence.*;
import lombok.*;
import lombok.experimental.SuperBuilder;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* Entidade que representa uma ordem de pagamento.
* Usa BigDecimal para valores financeiros e IDs para referências a outros
* módulos.
*/
@Entity
@Table(name = "payment_order", indexes = {
@Index(name = "idx_payment_order_batch", columnList = "payment_batch_id"),
@Index(name = "idx_payment_order_payroll_run", columnList = "payroll_run_id"),
@Index(name = "idx_payment_order_agent", columnList = "agent_id"),
@Index(name = "idx_payment_order_status", columnList = "status")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder
public class PaymentOrder extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "payment_batch_id", nullable = false)
private PaymentBatch paymentBatch;
@Column(name = "payroll_run_id")
private UUID payrollRunId; // Referência à execução de folha (rh.payroll_run)
@Column(name = "agent_id")
private UUID agentId; // Referência ao agente (rh.agent)
@Column(name = "bank_account_id")
private UUID bankAccountId; // Referência à conta bancária (rh.agent_bank_account)
@Column(name = "budget_line_id")
private UUID budgetLineId; // Referência à linha orçamentária (para execução orçamentária)
@Column(nullable = false, precision = 19, scale = 2, name = "gross_amount")
private BigDecimal grossAmount; // Valor bruto
@Column(nullable = false, precision = 19, scale = 2, name = "net_amount")
private BigDecimal netAmount; // Valor líquido (após descontos)
// --- Campos de Integridade Fiscal (RN03) ---
@Column(nullable = false, precision = 19, scale = 2, name = "tax_amount")
@Builder.Default
private BigDecimal taxAmount = BigDecimal.ZERO; // Valor do Imposto Retido
@Column(name = "tax_retention_type", length = 20)
private String taxRetentionType; // Ex: IR_20, IVA_18, NONE
@Column(name = "tax_collection_account_id")
private UUID taxCollectionAccountId; // Conta de Arrecadação (DGI) para onde vai o imposto
@Column(nullable = false, length = 20)
@Builder.Default
private String status = "CREATED"; // CREATED, SENT_TO_BANK, PAID, REJECTED - preparado para enum
@OneToMany(mappedBy = "paymentOrder", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<TreasuryPayment> treasuryPayments = new ArrayList<>();
}
@@ -0,0 +1,53 @@
package br.gov.sigefp.treasury.domain;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
/**
* Entidade que representa um item de conciliação bancária.
* Parte de BankReconciliation.
*/
@Entity
@Table(name = "reconciliation_item", indexes = {
@Index(name = "idx_reconciliation_item_reconciliation", columnList = "reconciliation_id"),
@Index(name = "idx_reconciliation_item_status", columnList = "match_status"),
@Index(name = "idx_reconciliation_item_date", columnList = "transaction_date")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ReconciliationItem {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "reconciliation_id", nullable = false)
private BankReconciliation reconciliation;
@Column(name = "transaction_date", nullable = false)
private LocalDate transactionDate;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "statement_amount", precision = 19, scale = 2)
private BigDecimal statementAmount; // Valor do extrato bancário
@Column(name = "system_amount", precision = 19, scale = 2)
private BigDecimal systemAmount; // Valor do sistema
@Column(name = "match_status", nullable = false, length = 20)
@Builder.Default
private String matchStatus = "PENDING"; // MATCHED, UNMATCHED, PENDING
@Column(name = "matched_transaction_id")
private UUID matchedTransactionId; // ID da transação do sistema que corresponde
}
@@ -0,0 +1,74 @@
package br.gov.sigefp.treasury.domain;
import br.gov.sigefp.common.domain.BaseEntity;
import jakarta.persistence.*;
import lombok.*;
import lombok.experimental.SuperBuilder;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Entidade que representa uma entrada de tesouraria.
* Similar a BudgetEntry no módulo de Orçamento.
* Rastreia todas as movimentações de tesouraria com histórico completo.
*/
@Entity
@Table(name = "treasury_entry", indexes = {
@Index(name = "idx_treasury_entry_cash_account", columnList = "cash_account_id"),
@Index(name = "idx_treasury_entry_type", columnList = "type"),
@Index(name = "idx_treasury_entry_status", columnList = "status"),
@Index(name = "idx_treasury_entry_date", columnList = "transaction_date"),
@Index(name = "idx_treasury_entry_payment_order", columnList = "payment_order_id")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder
public class TreasuryEntry extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "cash_account_id", nullable = false)
private CashAccount cashAccount;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 50)
private TreasuryEntryType type;
@Column(nullable = false, precision = 19, scale = 2)
private BigDecimal amount;
@Column(name = "transaction_date", nullable = false)
private LocalDate transactionDate; // Data da transação
@Column(name = "document_reference", length = 100)
private String documentReference; // Ex: "Decreto de Autorização", "Portaria de Pagamento"
@Column(columnDefinition = "TEXT")
private String description;
@Column(nullable = false, length = 20)
@Builder.Default
private String status = "DRAFT"; // DRAFT, PENDING_APPROVAL, APPROVED, REJECTED, EXECUTED
@Column(name = "approval_level")
private Integer approvalLevel; // Nível de aprovação necessário
@Column(name = "approved_by")
private UUID approvedBy; // Usuário que aprovou
@Column(name = "approved_at")
private LocalDateTime approvedAt;
@Column(name = "payment_order_id")
private UUID paymentOrderId; // Referência à ordem de pagamento (se aplicável)
@Column(name = "payment_batch_id")
private UUID paymentBatchId; // Referência ao lote de pagamento (se aplicável)
@Column(name = "budget_line_id")
private UUID budgetLineId; // Linha orçamentária relacionada
}
@@ -0,0 +1,34 @@
package br.gov.sigefp.treasury.domain;
/**
* Tipos de entradas de tesouraria.
* Similar a BudgetEntryType no módulo de Orçamento.
*/
public enum TreasuryEntryType {
// Autorizações
PAYMENT_AUTHORIZATION, // Autorização de Pagamento
BATCH_AUTHORIZATION, // Autorização de Lote
// Disponibilidades
CASH_AVAILABILITY, // Disponibilidade de Caixa
BANK_AVAILABILITY, // Disponibilidade Bancária
CASH_DEPOSIT, // Depósito em Caixa
CASH_WITHDRAWAL, // Saque de Caixa
// Programação
PAYMENT_SCHEDULING, // Programação de Pagamento
BATCH_SCHEDULING, // Programação de Lote
// Execução
PAYMENT_EXECUTION, // Execução de Pagamento
BATCH_EXECUTION, // Execução de Lote
// Conciliação
BANK_RECONCILIATION, // Conciliação Bancária
CASH_RECONCILIATION, // Conciliação de Caixa
// Ajustes
PAYMENT_CANCELLATION, // Cancelamento de Pagamento
PAYMENT_ADJUSTMENT, // Ajuste de Pagamento
CASH_ADJUSTMENT // Ajuste de Caixa
}
@@ -0,0 +1,43 @@
package br.gov.sigefp.treasury.domain;
import br.gov.sigefp.common.domain.BaseEntity;
import jakarta.persistence.*;
import lombok.*;
import java.time.Instant;
/**
* Entidade que representa um pagamento efetivado pela tesouraria.
* Usa Instant para timestamps.
*/
@Entity
@Table(name = "treasury_payment", indexes = {
@Index(name = "idx_treasury_payment_order", columnList = "payment_order_id"),
@Index(name = "idx_treasury_payment_status", columnList = "status"),
@Index(name = "idx_treasury_payment_paid", columnList = "paid_at")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TreasuryPayment extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "payment_order_id", nullable = false)
private PaymentOrder paymentOrder;
@Column(name = "paid_at")
private Instant paidAt; // Data/hora do pagamento efetivo
@Column(length = 100, name = "transaction_ref")
private String transactionRef; // Referência da transação bancária
@Column(nullable = false, length = 20)
@Builder.Default
private String status = "PENDING"; // PENDING, PAID, REJECTED, CANCELLED - preparado para enum
@Column(length = 1000)
private String message; // Mensagem de retorno do banco ou observações
}
@@ -0,0 +1,79 @@
package br.gov.sigefp.treasury.domain;
import br.gov.sigefp.common.domain.BaseEntity;
import jakarta.persistence.*;
import lombok.*;
import lombok.experimental.SuperBuilder;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* Entidade que representa um Plano de Tesouraria (PT).
* Define tetos de pagamento para períodos específicos (mensais).
* Conforme Master Plan - Module 2: Preventive Control.
*/
@Entity
@Table(name = "treasury_plan", indexes = {
@Index(name = "idx_treasury_plan_fiscal_year", columnList = "fiscal_year,reference_month"),
@Index(name = "idx_treasury_plan_status", columnList = "status"),
@Index(name = "idx_treasury_plan_period", columnList = "start_date,end_date")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder
public class TreasuryPlan extends BaseEntity {
@Column(name = "fiscal_year", nullable = false)
private Integer fiscalYear; // Ex: 2025
@Column(name = "reference_month", nullable = false)
private Integer referenceMonth; // 1-12
@Column(nullable = false, length = 20)
@Builder.Default
private String status = "DRAFT"; // DRAFT, APPROVED, CLOSED
@Column(name = "approved_ceiling", nullable = false, precision = 19, scale = 2)
private BigDecimal approvedCeiling; // Teto aprovado para o período
@Column(name = "executed_amount", nullable = false, precision = 19, scale = 2)
@Builder.Default
private BigDecimal executedAmount = BigDecimal.ZERO; // Atualizado via listeners
@Column(name = "start_date", nullable = false)
private LocalDate startDate; // Data de início do período
@Column(name = "end_date", nullable = false)
private LocalDate endDate; // Data de fim do período
@Column(name = "approved_by")
private java.util.UUID approvedBy; // Usuário que aprovou o plano
@Column(name = "approved_at")
private java.time.LocalDateTime approvedAt;
/**
* Calcula o valor disponível (teto - executado).
*/
public BigDecimal getAvailableAmount() {
return approvedCeiling.subtract(executedAmount);
}
/**
* Verifica se uma data está dentro do período do plano.
*/
public boolean isDateWithinPeriod(LocalDate date) {
return !date.isBefore(startDate) && !date.isAfter(endDate);
}
/**
* Verifica se o plano está aprovado e ativo.
*/
public boolean isActive() {
return "APPROVED".equals(status);
}
}
@@ -0,0 +1,112 @@
package br.gov.sigefp.treasury.integration;
import br.gov.sigefp.common.exception.BusinessException;
import br.gov.sigefp.common.exception.ResourceNotFoundException;
import br.gov.sigefp.treasury.domain.PaymentOrder;
import br.gov.sigefp.treasury.repository.PaymentOrderRepository;
import br.gov.sigefp.treasury.service.CashAccountService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.UUID;
/**
* Serviço de integração do módulo Tesouro com outros módulos.
* Similar a BudgetIntegrationService no módulo de Orçamento.
*/
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class TreasuryIntegrationService {
private final PaymentOrderRepository paymentOrderRepository;
private final CashAccountService cashAccountService;
/**
* Valida se uma ordem de pagamento pode ser criada.
* Verifica disponibilidade de caixa e integridade dos dados.
*/
public void validatePaymentOrder(UUID paymentOrderId) {
PaymentOrder paymentOrder = paymentOrderRepository.findById(paymentOrderId)
.orElseThrow(() -> new ResourceNotFoundException(
"Ordem de pagamento não encontrada: " + paymentOrderId));
// Validações básicas
if (paymentOrder.getNetAmount() == null || paymentOrder.getNetAmount().compareTo(BigDecimal.ZERO) <= 0) {
throw new BusinessException(
"Valor líquido da ordem de pagamento deve ser maior que zero",
"INVALID_AMOUNT",
org.springframework.http.HttpStatus.BAD_REQUEST);
}
// Validação de disponibilidade de caixa será feita quando a ordem for programada
// (requer cashAccountId na PaymentOrder, que pode ser adicionado no futuro)
log.debug("Ordem de pagamento validada: paymentOrderId={}", paymentOrderId);
}
/**
* Valida disponibilidade de caixa para um pagamento.
*/
public void validateCashAvailability(UUID cashAccountId, BigDecimal amount) {
if (cashAccountId == null) {
throw new BusinessException(
"Conta de caixa é obrigatória para validação",
"MISSING_CASH_ACCOUNT",
org.springframework.http.HttpStatus.BAD_REQUEST);
}
BigDecimal available = cashAccountService.getAvailableBalance(cashAccountId);
if (amount.compareTo(available) > 0) {
throw new BusinessException(
String.format("Saldo disponível insuficiente. Disponível: %s, Solicitado: %s",
available, amount),
"INSUFFICIENT_CASH",
org.springframework.http.HttpStatus.CONFLICT);
}
log.debug("Disponibilidade de caixa validada: accountId={}, amount={}, available={}",
cashAccountId, amount, available);
}
/**
* Registra a execução de um pagamento.
* Atualiza saldos e cria entradas de tesouraria.
*/
public void registerPaymentExecution(UUID paymentOrderId, BigDecimal amount, UUID cashAccountId) {
PaymentOrder paymentOrder = paymentOrderRepository.findById(paymentOrderId)
.orElseThrow(() -> new ResourceNotFoundException(
"Ordem de pagamento não encontrada: " + paymentOrderId));
// Validar disponibilidade
validateCashAvailability(cashAccountId, amount);
// Atualizar saldo da conta
cashAccountService.updateBalance(cashAccountId, amount, "OUTFLOW");
log.info("Execução de pagamento registrada: paymentOrderId={}, amount={}, accountId={}",
paymentOrderId, amount, cashAccountId);
}
/**
* Atualiza execução orçamentária após pagamento.
* Este método será chamado pelo TreasuryPaymentService quando um pagamento for confirmado.
*/
public void updateBudgetExecution(UUID paymentOrderId) {
PaymentOrder paymentOrder = paymentOrderRepository.findById(paymentOrderId)
.orElseThrow(() -> new ResourceNotFoundException(
"Ordem de pagamento não encontrada: " + paymentOrderId));
// A atualização da execução orçamentária já é feita pelo TreasuryPaymentService
// Este método pode ser usado para validações adicionais ou notificações
log.debug("Execução orçamentária atualizada para ordem de pagamento: paymentOrderId={}",
paymentOrderId);
}
}
@@ -0,0 +1,19 @@
package br.gov.sigefp.treasury.repository;
import br.gov.sigefp.treasury.domain.Approval;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.UUID;
@Repository
public interface ApprovalRepository extends JpaRepository<Approval, UUID> {
List<Approval> findByAuthorizationId(UUID authorizationId);
List<Approval> findByApprovedBy(UUID approvedBy);
List<Approval> findByAuthorizationIdOrderByLevelAsc(UUID authorizationId);
}
@@ -0,0 +1,37 @@
package br.gov.sigefp.treasury.repository;
import br.gov.sigefp.treasury.domain.BankReconciliation;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface BankReconciliationRepository extends JpaRepository<BankReconciliation, UUID> {
@Query("SELECT br FROM BankReconciliation br WHERE br.cashAccount.id = :cashAccountId")
List<BankReconciliation> findByCashAccountId(@Param("cashAccountId") UUID cashAccountId);
List<BankReconciliation> findByStatus(String status);
@Query("SELECT br FROM BankReconciliation br WHERE br.cashAccount.id = :cashAccountId " +
"AND br.reconciliationDate BETWEEN :startDate AND :endDate")
List<BankReconciliation> findByCashAccountIdAndDateRange(
@Param("cashAccountId") UUID cashAccountId,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate
);
@Query("SELECT br FROM BankReconciliation br WHERE br.cashAccount.id = :cashAccountId " +
"AND br.status = 'PENDING' ORDER BY br.reconciliationDate DESC")
List<BankReconciliation> findPendingByCashAccount(@Param("cashAccountId") UUID cashAccountId);
@Query("SELECT br FROM BankReconciliation br WHERE br.cashAccount.id = :cashAccountId " +
"ORDER BY br.reconciliationDate DESC")
Optional<BankReconciliation> findFirstByCashAccountIdOrderByReconciliationDateDesc(@Param("cashAccountId") UUID cashAccountId);
}
@@ -0,0 +1,29 @@
package br.gov.sigefp.treasury.repository;
import br.gov.sigefp.treasury.domain.CashAccount;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface CashAccountRepository extends JpaRepository<CashAccount, UUID> {
Optional<CashAccount> findByCode(String code);
List<CashAccount> findByType(String type);
List<CashAccount> findByIsActiveTrue();
List<CashAccount> findByBankId(UUID bankId);
@Query("SELECT ca FROM CashAccount ca WHERE ca.type = 'BANK_ACCOUNT' AND ca.isActive = true")
List<CashAccount> findActiveBankAccounts();
@Query("SELECT ca FROM CashAccount ca WHERE ca.type = 'CASH' AND ca.isActive = true")
List<CashAccount> findActiveCashAccounts();
}
@@ -0,0 +1,49 @@
package br.gov.sigefp.treasury.repository;
import br.gov.sigefp.treasury.domain.CashFlow;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
@Repository
public interface CashFlowRepository extends JpaRepository<CashFlow, UUID> {
@Query("SELECT cf FROM CashFlow cf WHERE cf.cashAccount.id = :cashAccountId")
Page<CashFlow> findByCashAccountId(@Param("cashAccountId") UUID cashAccountId, Pageable pageable);
@Query("SELECT cf FROM CashFlow cf WHERE cf.cashAccount.id = :cashAccountId " +
"AND cf.transactionDate BETWEEN :startDate AND :endDate")
Page<CashFlow> findByCashAccountIdAndDateRange(
@Param("cashAccountId") UUID cashAccountId,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate,
Pageable pageable
);
List<CashFlow> findByType(String type);
@Query("SELECT cf FROM CashFlow cf WHERE cf.cashAccount.id = :cashAccountId AND cf.type = :type")
List<CashFlow> findByCashAccountIdAndType(@Param("cashAccountId") UUID cashAccountId,
@Param("type") String type);
@Query("SELECT SUM(cf.amount) FROM CashFlow cf WHERE cf.cashAccount.id = :cashAccountId " +
"AND cf.type = :type AND cf.transactionDate BETWEEN :startDate AND :endDate")
java.math.BigDecimal calculateTotalByAccountAndType(
@Param("cashAccountId") UUID cashAccountId,
@Param("type") String type,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate
);
@Query("SELECT cf FROM CashFlow cf WHERE cf.referenceType = :referenceType " +
"AND cf.referenceId = :referenceId")
List<CashFlow> findByReference(@Param("referenceType") String referenceType,
@Param("referenceId") UUID referenceId);
}
@@ -0,0 +1,30 @@
package br.gov.sigefp.treasury.repository;
import br.gov.sigefp.treasury.domain.PaymentAuthorization;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.UUID;
@Repository
public interface PaymentAuthorizationRepository extends JpaRepository<PaymentAuthorization, UUID> {
List<PaymentAuthorization> findByPaymentOrderId(UUID paymentOrderId);
List<PaymentAuthorization> findByPaymentBatchId(UUID paymentBatchId);
List<PaymentAuthorization> findByStatus(String status);
List<PaymentAuthorization> findByRequestedBy(UUID requestedBy);
@Query("SELECT pa FROM PaymentAuthorization pa WHERE pa.status = 'PENDING' " +
"AND pa.currentApprovalLevel <= :maxLevel")
List<PaymentAuthorization> findPendingApprovals(@Param("maxLevel") Integer maxLevel);
@Query("SELECT pa FROM PaymentAuthorization pa WHERE pa.status = 'PENDING' " +
"AND pa.currentApprovalLevel = :level")
List<PaymentAuthorization> findPendingByLevel(@Param("level") Integer level);
}
@@ -0,0 +1,30 @@
package br.gov.sigefp.treasury.repository;
import br.gov.sigefp.treasury.domain.PaymentBatch;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.UUID;
@Repository
public interface PaymentBatchRepository extends JpaRepository<PaymentBatch, UUID> {
List<PaymentBatch> findByPeriodId(Long periodId);
List<PaymentBatch> findByMinistryId(UUID ministryId);
List<PaymentBatch> findByStatus(String status);
@Query("SELECT pb FROM PaymentBatch pb WHERE pb.periodId = :periodId AND pb.ministryId = :ministryId")
List<PaymentBatch> findByPeriodIdAndMinistryId(@Param("periodId") Long periodId, @Param("ministryId") UUID ministryId);
@Query("SELECT pb FROM PaymentBatch pb WHERE pb.periodId = :periodId AND pb.status = :status")
List<PaymentBatch> findByPeriodIdAndStatus(@Param("periodId") Long periodId, @Param("status") String status);
@Query("SELECT pb FROM PaymentBatch pb WHERE pb.ministryId = :ministryId AND pb.status = :status")
List<PaymentBatch> findByMinistryIdAndStatus(@Param("ministryId") UUID ministryId, @Param("status") String status);
}
@@ -0,0 +1,24 @@
package br.gov.sigefp.treasury.repository;
import br.gov.sigefp.treasury.domain.PaymentOrder;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.UUID;
@Repository
public interface PaymentOrderRepository extends JpaRepository<PaymentOrder, UUID> {
List<PaymentOrder> findByPaymentBatchId(UUID paymentBatchId);
List<PaymentOrder> findByStatus(String status);
@Query("SELECT po FROM PaymentOrder po WHERE po.paymentBatch.id = :batchId AND po.status = :status")
List<PaymentOrder> findByPaymentBatchIdAndStatus(@Param("batchId") UUID batchId, @Param("status") String status);
@Query("SELECT po FROM PaymentOrder po WHERE po.payrollRunId = :payrollRunId")
List<PaymentOrder> findByPayrollRunId(@Param("payrollRunId") UUID payrollRunId);
}
@@ -0,0 +1,19 @@
package br.gov.sigefp.treasury.repository;
import br.gov.sigefp.treasury.domain.ReconciliationItem;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.UUID;
@Repository
public interface ReconciliationItemRepository extends JpaRepository<ReconciliationItem, UUID> {
List<ReconciliationItem> findByReconciliationId(UUID reconciliationId);
List<ReconciliationItem> findByMatchStatus(String matchStatus);
List<ReconciliationItem> findByReconciliationIdAndMatchStatus(UUID reconciliationId, String matchStatus);
}
@@ -0,0 +1,54 @@
package br.gov.sigefp.treasury.repository;
import br.gov.sigefp.treasury.domain.TreasuryEntry;
import br.gov.sigefp.treasury.domain.TreasuryEntryType;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
@Repository
public interface TreasuryEntryRepository extends JpaRepository<TreasuryEntry, UUID> {
@Query("SELECT te FROM TreasuryEntry te WHERE te.cashAccount.id = :cashAccountId")
Page<TreasuryEntry> findByCashAccountId(@Param("cashAccountId") UUID cashAccountId, Pageable pageable);
Page<TreasuryEntry> findByType(TreasuryEntryType type, Pageable pageable);
Page<TreasuryEntry> findByStatus(String status, Pageable pageable);
@Query("SELECT te FROM TreasuryEntry te WHERE te.cashAccount.id = :cashAccountId AND te.type = :type")
Page<TreasuryEntry> findByCashAccountIdAndType(@Param("cashAccountId") UUID cashAccountId,
@Param("type") TreasuryEntryType type, Pageable pageable);
@Query("SELECT te FROM TreasuryEntry te WHERE te.cashAccount.id = :cashAccountId AND te.status = :status")
Page<TreasuryEntry> findByCashAccountIdAndStatus(@Param("cashAccountId") UUID cashAccountId,
@Param("status") String status, Pageable pageable);
@Query("SELECT te FROM TreasuryEntry te WHERE te.paymentOrderId = :paymentOrderId")
List<TreasuryEntry> findByPaymentOrderId(@Param("paymentOrderId") UUID paymentOrderId);
@Query("SELECT te FROM TreasuryEntry te WHERE te.paymentBatchId = :paymentBatchId")
List<TreasuryEntry> findByPaymentBatchId(@Param("paymentBatchId") UUID paymentBatchId);
@Query("SELECT te FROM TreasuryEntry te WHERE te.cashAccount.id = :cashAccountId " +
"AND te.transactionDate BETWEEN :startDate AND :endDate")
List<TreasuryEntry> findByCashAccountIdAndDateRange(
@Param("cashAccountId") UUID cashAccountId,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate
);
@Query("SELECT SUM(te.amount) FROM TreasuryEntry te WHERE te.cashAccount.id = :cashAccountId " +
"AND te.type IN :types AND te.status = 'EXECUTED'")
java.math.BigDecimal calculateTotalByCashAccountAndTypes(
@Param("cashAccountId") UUID cashAccountId,
@Param("types") List<TreasuryEntryType> types
);
}
@@ -0,0 +1,22 @@
package br.gov.sigefp.treasury.repository;
import br.gov.sigefp.treasury.domain.TreasuryPayment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.UUID;
@Repository
public interface TreasuryPaymentRepository extends JpaRepository<TreasuryPayment, UUID> {
List<TreasuryPayment> findByPaymentOrderId(UUID paymentOrderId);
List<TreasuryPayment> findByStatus(String status);
@Query("SELECT tp FROM TreasuryPayment tp WHERE tp.paymentOrder.id = :paymentOrderId AND tp.status = :status")
List<TreasuryPayment> findByPaymentOrderIdAndStatus(@Param("paymentOrderId") UUID paymentOrderId, @Param("status") String status);
}
@@ -0,0 +1,59 @@
package br.gov.sigefp.treasury.repository;
import br.gov.sigefp.treasury.domain.TreasuryPlan;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Repository para gestão de Planos de Tesouraria.
*/
@Repository
public interface TreasuryPlanRepository extends JpaRepository<TreasuryPlan, UUID> {
/**
* Encontra o plano ativo para uma data específica.
*/
@Query("SELECT tp FROM TreasuryPlan tp WHERE tp.status = 'APPROVED' " +
"AND :date >= tp.startDate AND :date <= tp.endDate")
Optional<TreasuryPlan> findActivePlanForDate(@Param("date") LocalDate date);
/**
* Encontra todos os planos aprovados para um período.
*/
@Query("SELECT tp FROM TreasuryPlan tp WHERE tp.status = 'APPROVED' " +
"AND tp.startDate <= :endDate AND tp.endDate >= :startDate")
List<TreasuryPlan> findApprovedPlansForPeriod(@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
/**
* Encontra planos por status.
*/
List<TreasuryPlan> findByStatus(String status);
/**
* Encontra planos por ano fiscal e mês de referência.
*/
@Query("SELECT tp FROM TreasuryPlan tp WHERE tp.fiscalYear = :fiscalYear " +
"AND tp.referenceMonth = :referenceMonth")
Optional<TreasuryPlan> findByFiscalYearAndReferenceMonth(@Param("fiscalYear") Integer fiscalYear,
@Param("referenceMonth") Integer referenceMonth);
/**
* Calcula o total executado para um plano específico.
* Soma todos os pagamentos autorizados relacionados ao período do plano.
*/
@Query("SELECT COALESCE(SUM(po.grossAmount), 0) FROM PaymentOrder po " +
"WHERE po.paymentBatch.id IN " +
"(SELECT pb.id FROM PaymentBatch pb WHERE pb.status = 'SENT_TO_BANK' OR pb.status = 'EXECUTED') " +
"AND po.createdAt >= :startDate AND po.createdAt <= :endDate")
java.math.BigDecimal calculateExecutedAmountForPeriod(@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
}
@@ -0,0 +1,244 @@
package br.gov.sigefp.treasury.service;
import br.gov.sigefp.common.exception.BusinessException;
import br.gov.sigefp.common.exception.ResourceNotFoundException;
import br.gov.sigefp.treasury.api.dto.BankReconciliationDTO;
import br.gov.sigefp.treasury.api.dto.CreateBankReconciliationDTO;
import br.gov.sigefp.treasury.api.dto.ReconciliationItemDTO;
import br.gov.sigefp.treasury.domain.BankReconciliation;
import br.gov.sigefp.treasury.domain.CashAccount;
import br.gov.sigefp.treasury.domain.ReconciliationItem;
import br.gov.sigefp.treasury.repository.BankReconciliationRepository;
import br.gov.sigefp.treasury.repository.CashAccountRepository;
import br.gov.sigefp.treasury.repository.ReconciliationItemRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Serviço de aplicação para gestão de conciliação bancária.
*/
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class BankReconciliationService {
private final BankReconciliationRepository reconciliationRepository;
private final CashAccountRepository cashAccountRepository;
private final ReconciliationItemRepository reconciliationItemRepository;
private final CashAccountService cashAccountService;
public BankReconciliationDTO importStatement(CreateBankReconciliationDTO dto) {
CashAccount cashAccount = cashAccountRepository.findById(dto.getCashAccountId())
.orElseThrow(() -> new ResourceNotFoundException(
"Conta não encontrada: " + dto.getCashAccountId()));
BigDecimal systemBalance = cashAccountService.getCurrentBalance(dto.getCashAccountId());
BigDecimal difference = dto.getStatementBalance().subtract(systemBalance);
BankReconciliation reconciliation = BankReconciliation.builder()
.cashAccount(cashAccount)
.reconciliationDate(dto.getReconciliationDate())
.statementBalance(dto.getStatementBalance())
.systemBalance(systemBalance)
.difference(difference)
.status("PENDING")
.build();
BankReconciliation saved = reconciliationRepository.save(reconciliation);
// Criar itens de conciliação se fornecidos
if (dto.getReconciliationItems() != null && !dto.getReconciliationItems().isEmpty()) {
for (ReconciliationItemDTO itemDTO : dto.getReconciliationItems()) {
ReconciliationItem item = ReconciliationItem.builder()
.reconciliation(saved)
.transactionDate(itemDTO.getTransactionDate())
.description(itemDTO.getDescription())
.statementAmount(itemDTO.getStatementAmount())
.systemAmount(itemDTO.getSystemAmount())
.matchStatus("PENDING")
.build();
reconciliationItemRepository.save(item);
saved.getReconciliationItems().add(item);
}
}
log.info("Conciliação bancária criada: id={}, accountId={}, difference={}",
saved.getId(), dto.getCashAccountId(), difference);
return toDTO(saved);
}
public BankReconciliationDTO reconcile(UUID reconciliationId) {
BankReconciliation reconciliation = reconciliationRepository.findById(reconciliationId)
.orElseThrow(() -> new ResourceNotFoundException(
"Conciliação não encontrada: " + reconciliationId));
if (!"PENDING".equals(reconciliation.getStatus())) {
throw new BusinessException(
"Apenas conciliações pendentes podem ser reconciliadas",
"INVALID_STATUS",
org.springframework.http.HttpStatus.CONFLICT);
}
// Tentar matching automático
performAutomaticMatching(reconciliation);
// Verificar se todos os itens foram reconciliados
long unmatchedCount = reconciliation.getReconciliationItems().stream()
.filter(item -> !"MATCHED".equals(item.getMatchStatus()))
.count();
if (unmatchedCount == 0 && reconciliation.getDifference().compareTo(BigDecimal.ZERO) == 0) {
reconciliation.setStatus("RECONCILED");
} else if (reconciliation.getDifference().compareTo(BigDecimal.ZERO) != 0) {
reconciliation.setStatus("DISCREPANCY");
}
BankReconciliation saved = reconciliationRepository.save(reconciliation);
return toDTO(saved);
}
public void matchItem(UUID reconciliationId, UUID itemId, UUID systemTransactionId) {
BankReconciliation reconciliation = reconciliationRepository.findById(reconciliationId)
.orElseThrow(() -> new ResourceNotFoundException(
"Conciliação não encontrada: " + reconciliationId));
ReconciliationItem item = reconciliationItemRepository.findById(itemId)
.orElseThrow(() -> new ResourceNotFoundException(
"Item não encontrado: " + itemId));
if (!item.getReconciliation().getId().equals(reconciliationId)) {
throw new BusinessException(
"Item não pertence a esta conciliação",
"INVALID_ITEM",
org.springframework.http.HttpStatus.BAD_REQUEST);
}
item.setMatchStatus("MATCHED");
item.setMatchedTransactionId(systemTransactionId);
reconciliationItemRepository.save(item);
log.info("Item de conciliação correspondido: itemId={}, transactionId={}",
itemId, systemTransactionId);
}
public BankReconciliationDTO finalize(UUID reconciliationId, UUID reconciledBy) {
BankReconciliation reconciliation = reconciliationRepository.findById(reconciliationId)
.orElseThrow(() -> new ResourceNotFoundException(
"Conciliação não encontrada: " + reconciliationId));
if (!"RECONCILED".equals(reconciliation.getStatus()) &&
!"DISCREPANCY".equals(reconciliation.getStatus())) {
throw new BusinessException(
"Apenas conciliações reconciliadas ou com discrepância podem ser finalizadas",
"INVALID_STATUS",
org.springframework.http.HttpStatus.CONFLICT);
}
reconciliation.setStatus("RECONCILED");
reconciliation.setReconciledBy(reconciledBy);
reconciliation.setReconciledAt(LocalDateTime.now());
// Atualizar saldo da conta se houver diferença ajustada
if (reconciliation.getDifference().compareTo(BigDecimal.ZERO) != 0) {
// Ajustar saldo do sistema para corresponder ao extrato
cashAccountService.updateBalance(
reconciliation.getCashAccount().getId(),
reconciliation.getDifference(),
reconciliation.getDifference().compareTo(BigDecimal.ZERO) > 0 ? "INFLOW" : "OUTFLOW");
}
BankReconciliation saved = reconciliationRepository.save(reconciliation);
log.info("Conciliação finalizada: id={}, reconciledBy={}", reconciliationId, reconciledBy);
return toDTO(saved);
}
@Transactional(readOnly = true)
public BankReconciliationDTO findById(UUID id) {
BankReconciliation reconciliation = reconciliationRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Conciliação não encontrada: " + id));
return toDTO(reconciliation);
}
@Transactional(readOnly = true)
public List<BankReconciliationDTO> findByCashAccountId(UUID cashAccountId) {
return reconciliationRepository.findByCashAccountId(cashAccountId).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
@Transactional(readOnly = true)
public List<ReconciliationItemDTO> findUnmatchedItems(UUID reconciliationId) {
BankReconciliation reconciliation = reconciliationRepository.findById(reconciliationId)
.orElseThrow(() -> new ResourceNotFoundException(
"Conciliação não encontrada: " + reconciliationId));
return reconciliation.getReconciliationItems().stream()
.filter(item -> !"MATCHED".equals(item.getMatchStatus()))
.map(this::itemToDTO)
.collect(Collectors.toList());
}
/**
* Realiza matching automático de itens baseado em data e valor.
*/
private void performAutomaticMatching(BankReconciliation reconciliation) {
// Implementação simplificada: matching por data e valor exato
// Em produção, poderia usar algoritmos mais sofisticados
for (ReconciliationItem item : reconciliation.getReconciliationItems()) {
if ("PENDING".equals(item.getMatchStatus()) && item.getSystemAmount() != null) {
// Verificar se há correspondência exata
if (item.getStatementAmount() != null &&
item.getStatementAmount().compareTo(item.getSystemAmount()) == 0) {
item.setMatchStatus("MATCHED");
reconciliationItemRepository.save(item);
}
}
}
}
private BankReconciliationDTO toDTO(BankReconciliation reconciliation) {
List<ReconciliationItemDTO> items = reconciliation.getReconciliationItems().stream()
.map(this::itemToDTO)
.collect(Collectors.toList());
return BankReconciliationDTO.builder()
.id(reconciliation.getId())
.cashAccountId(reconciliation.getCashAccount().getId())
.reconciliationDate(reconciliation.getReconciliationDate())
.statementBalance(reconciliation.getStatementBalance())
.systemBalance(reconciliation.getSystemBalance())
.difference(reconciliation.getDifference())
.status(reconciliation.getStatus())
.reconciledBy(reconciliation.getReconciledBy())
.reconciledAt(reconciliation.getReconciledAt())
.items(items)
.createdAt(reconciliation.getCreatedAt())
.updatedAt(reconciliation.getUpdatedAt())
.build();
}
private ReconciliationItemDTO itemToDTO(ReconciliationItem item) {
return ReconciliationItemDTO.builder()
.id(item.getId())
.reconciliationId(item.getReconciliation().getId())
.transactionDate(item.getTransactionDate())
.description(item.getDescription())
.statementAmount(item.getStatementAmount())
.systemAmount(item.getSystemAmount())
.matchStatus(item.getMatchStatus())
.matchedTransactionId(item.getMatchedTransactionId())
.build();
}
}
@@ -0,0 +1,188 @@
package br.gov.sigefp.treasury.service;
import br.gov.sigefp.common.exception.BusinessException;
import br.gov.sigefp.common.exception.ResourceNotFoundException;
import br.gov.sigefp.treasury.api.dto.CashAccountDTO;
import br.gov.sigefp.treasury.api.dto.CreateCashAccountDTO;
import br.gov.sigefp.treasury.domain.CashAccount;
import br.gov.sigefp.treasury.repository.CashAccountRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Serviço de aplicação para gestão de contas de caixa e bancárias.
*/
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class CashAccountService {
private final CashAccountRepository cashAccountRepository;
public CashAccountDTO create(CreateCashAccountDTO dto) {
// Validar código único
if (cashAccountRepository.findByCode(dto.getCode()).isPresent()) {
throw new BusinessException(
"Já existe uma conta com o código: " + dto.getCode(),
"DUPLICATE_CODE",
org.springframework.http.HttpStatus.CONFLICT);
}
// Validar se bankId é obrigatório para BANK_ACCOUNT
if ("BANK_ACCOUNT".equals(dto.getType()) && dto.getBankId() == null) {
throw new BusinessException(
"BankId é obrigatório para contas bancárias",
"MISSING_BANK_ID",
org.springframework.http.HttpStatus.BAD_REQUEST);
}
CashAccount cashAccount = CashAccount.builder()
.code(dto.getCode())
.name(dto.getName())
.type(dto.getType())
.orgUnitId(dto.getOrgUnitId())
.bankId(dto.getBankId())
.accountNumber(dto.getAccountNumber())
.branchCode(dto.getBranchCode())
.currency(dto.getCurrency() != null ? dto.getCurrency() : "XOF")
.isActive(dto.getIsActive() != null ? dto.getIsActive() : true)
.currentBalance(BigDecimal.ZERO)
.availableBalance(BigDecimal.ZERO)
.build();
CashAccount saved = cashAccountRepository.save(cashAccount);
log.info("Conta de caixa criada: id={}, code={}, name={}", saved.getId(), saved.getCode(), saved.getName());
return toDTO(saved);
}
@Transactional(readOnly = true)
public CashAccountDTO findById(UUID id) {
CashAccount cashAccount = cashAccountRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Conta não encontrada: " + id));
return toDTO(cashAccount);
}
@Transactional(readOnly = true)
public List<CashAccountDTO> findAll() {
return cashAccountRepository.findAll().stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
@Transactional(readOnly = true)
public List<CashAccountDTO> findActive() {
return cashAccountRepository.findByIsActiveTrue().stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
@Transactional(readOnly = true)
public List<CashAccountDTO> findByType(String type) {
return cashAccountRepository.findByType(type).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
@Transactional(readOnly = true)
public BigDecimal getAvailableBalance(UUID cashAccountId) {
CashAccount cashAccount = cashAccountRepository.findById(cashAccountId)
.orElseThrow(() -> new ResourceNotFoundException("Conta não encontrada: " + cashAccountId));
return cashAccount.getAvailableBalance();
}
@Transactional(readOnly = true)
public BigDecimal getCurrentBalance(UUID cashAccountId) {
CashAccount cashAccount = cashAccountRepository.findById(cashAccountId)
.orElseThrow(() -> new ResourceNotFoundException("Conta não encontrada: " + cashAccountId));
return cashAccount.getCurrentBalance();
}
/**
* Atualiza o saldo da conta após uma operação.
*
* @param cashAccountId ID da conta
* @param amount Valor da operação (positivo para entrada, negativo para
* saída)
* @param operation Tipo de operação (INFLOW, OUTFLOW)
*/
public void updateBalance(UUID cashAccountId, BigDecimal amount, String operation) {
CashAccount cashAccount = cashAccountRepository.findById(cashAccountId)
.orElseThrow(() -> new ResourceNotFoundException("Conta não encontrada: " + cashAccountId));
if ("INFLOW".equals(operation)) {
cashAccount.setCurrentBalance(cashAccount.getCurrentBalance().add(amount));
cashAccount.setAvailableBalance(cashAccount.getAvailableBalance().add(amount));
} else if ("OUTFLOW".equals(operation)) {
cashAccount.setCurrentBalance(cashAccount.getCurrentBalance().subtract(amount));
cashAccount.setAvailableBalance(cashAccount.getAvailableBalance().subtract(amount));
}
cashAccountRepository.save(cashAccount);
log.debug("Saldo atualizado: accountId={}, operation={}, amount={}, newBalance={}",
cashAccountId, operation, amount, cashAccount.getCurrentBalance());
}
/**
* Compromete saldo disponível (reduz availableBalance sem alterar
* currentBalance).
* Usado quando um pagamento é programado mas ainda não executado.
*/
public void commitBalance(UUID cashAccountId, BigDecimal amount) {
CashAccount cashAccount = cashAccountRepository.findById(cashAccountId)
.orElseThrow(() -> new ResourceNotFoundException("Conta não encontrada: " + cashAccountId));
if (cashAccount.getAvailableBalance().compareTo(amount) < 0) {
throw new BusinessException(
String.format("Saldo disponível insuficiente. Disponível: %s, Solicitado: %s",
cashAccount.getAvailableBalance(), amount),
"INSUFFICIENT_BALANCE",
org.springframework.http.HttpStatus.CONFLICT);
}
cashAccount.setAvailableBalance(cashAccount.getAvailableBalance().subtract(amount));
cashAccountRepository.save(cashAccount);
log.debug("Saldo comprometido: accountId={}, amount={}, newAvailableBalance={}",
cashAccountId, amount, cashAccount.getAvailableBalance());
}
/**
* Libera saldo comprometido (aumenta availableBalance).
* Usado quando um pagamento programado é cancelado.
*/
public void releaseBalance(UUID cashAccountId, BigDecimal amount) {
CashAccount cashAccount = cashAccountRepository.findById(cashAccountId)
.orElseThrow(() -> new ResourceNotFoundException("Conta não encontrada: " + cashAccountId));
cashAccount.setAvailableBalance(cashAccount.getAvailableBalance().add(amount));
cashAccountRepository.save(cashAccount);
log.debug("Saldo liberado: accountId={}, amount={}, newAvailableBalance={}",
cashAccountId, amount, cashAccount.getAvailableBalance());
}
private CashAccountDTO toDTO(CashAccount cashAccount) {
return CashAccountDTO.builder()
.id(cashAccount.getId())
.code(cashAccount.getCode())
.name(cashAccount.getName())
.type(cashAccount.getType())
.bankId(cashAccount.getBankId())
.accountNumber(cashAccount.getAccountNumber())
.branchCode(cashAccount.getBranchCode())
.currency(cashAccount.getCurrency())
.isActive(cashAccount.getIsActive())
.currentBalance(cashAccount.getCurrentBalance())
.availableBalance(cashAccount.getAvailableBalance())
.createdAt(cashAccount.getCreatedAt())
.updatedAt(cashAccount.getUpdatedAt())
.build();
}
}
@@ -0,0 +1,153 @@
package br.gov.sigefp.treasury.service;
import br.gov.sigefp.common.exception.ResourceNotFoundException;
import br.gov.sigefp.treasury.api.dto.CashFlowDTO;
import br.gov.sigefp.treasury.api.dto.CreateCashFlowDTO;
import br.gov.sigefp.treasury.domain.CashAccount;
import br.gov.sigefp.treasury.domain.CashFlow;
import br.gov.sigefp.treasury.repository.CashAccountRepository;
import br.gov.sigefp.treasury.repository.CashFlowRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Serviço de aplicação para gestão de fluxo de caixa.
*/
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class CashFlowService {
private final CashFlowRepository cashFlowRepository;
private final CashAccountRepository cashAccountRepository;
private final CashAccountService cashAccountService;
public CashFlowDTO registerFlow(CreateCashFlowDTO dto) {
CashAccount cashAccount = cashAccountRepository.findById(dto.getCashAccountId())
.orElseThrow(() -> new ResourceNotFoundException(
"Conta não encontrada: " + dto.getCashAccountId()));
// Calcular saldo após a transação
BigDecimal currentBalance = cashAccount.getCurrentBalance();
BigDecimal balanceAfter;
if ("INFLOW".equals(dto.getType())) {
balanceAfter = currentBalance.add(dto.getAmount());
} else {
balanceAfter = currentBalance.subtract(dto.getAmount());
}
CashFlow cashFlow = CashFlow.builder()
.cashAccount(cashAccount)
.transactionDate(dto.getTransactionDate())
.type(dto.getType())
.amount(dto.getAmount())
.description(dto.getDescription())
.referenceId(dto.getReferenceId())
.referenceType(dto.getReferenceType())
.balanceAfter(balanceAfter)
.build();
CashFlow saved = cashFlowRepository.save(cashFlow);
// Atualizar saldo da conta
cashAccountService.updateBalance(dto.getCashAccountId(), dto.getAmount(), dto.getType());
log.info("Fluxo de caixa registrado: id={}, accountId={}, type={}, amount={}",
saved.getId(), dto.getCashAccountId(), dto.getType(), dto.getAmount());
return toDTO(saved);
}
@Transactional(readOnly = true)
public CashFlowDTO findById(UUID id) {
CashFlow cashFlow = cashFlowRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Fluxo não encontrado: " + id));
return toDTO(cashFlow);
}
@Transactional(readOnly = true)
public Page<CashFlowDTO> findByCashAccountId(UUID cashAccountId, Pageable pageable) {
return cashFlowRepository.findByCashAccountId(cashAccountId, pageable)
.map(this::toDTO);
}
@Transactional(readOnly = true)
public Page<CashFlowDTO> findByCashAccountIdAndDateRange(
UUID cashAccountId, LocalDate startDate, LocalDate endDate, Pageable pageable) {
return cashFlowRepository.findByCashAccountIdAndDateRange(
cashAccountId, startDate, endDate, pageable)
.map(this::toDTO);
}
@Transactional(readOnly = true)
public BigDecimal calculateProjectedBalance(UUID cashAccountId, LocalDate targetDate) {
CashAccount cashAccount = cashAccountRepository.findById(cashAccountId)
.orElseThrow(() -> new ResourceNotFoundException("Conta não encontrada: " + cashAccountId));
BigDecimal currentBalance = cashAccount.getCurrentBalance();
LocalDate today = LocalDate.now();
if (targetDate.isBefore(today)) {
return currentBalance; // Não projeta para o passado
}
// Calcular projeção baseada em fluxos futuros programados
// (assumindo que há uma forma de identificar fluxos programados)
// Por enquanto, retorna o saldo atual
return currentBalance;
}
@Transactional(readOnly = true)
public Map<String, BigDecimal> getFlowSummary(UUID cashAccountId, LocalDate startDate, LocalDate endDate) {
BigDecimal totalInflow = cashFlowRepository.calculateTotalByAccountAndType(
cashAccountId, "INFLOW", startDate, endDate);
BigDecimal totalOutflow = cashFlowRepository.calculateTotalByAccountAndType(
cashAccountId, "OUTFLOW", startDate, endDate);
if (totalInflow == null) totalInflow = BigDecimal.ZERO;
if (totalOutflow == null) totalOutflow = BigDecimal.ZERO;
return Map.of(
"totalInflow", totalInflow,
"totalOutflow", totalOutflow,
"netFlow", totalInflow.subtract(totalOutflow)
);
}
@Transactional(readOnly = true)
public List<CashFlowDTO> findByReference(String referenceType, UUID referenceId) {
return cashFlowRepository.findByReference(referenceType, referenceId).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
private CashFlowDTO toDTO(CashFlow cashFlow) {
return CashFlowDTO.builder()
.id(cashFlow.getId())
.cashAccountId(cashFlow.getCashAccount().getId())
.transactionDate(cashFlow.getTransactionDate())
.type(cashFlow.getType())
.amount(cashFlow.getAmount())
.description(cashFlow.getDescription())
.referenceId(cashFlow.getReferenceId())
.referenceType(cashFlow.getReferenceType())
.balanceAfter(cashFlow.getBalanceAfter())
.createdAt(cashFlow.getCreatedAt())
.updatedAt(cashFlow.getUpdatedAt())
.build();
}
}
@@ -0,0 +1,223 @@
package br.gov.sigefp.treasury.service;
import br.gov.sigefp.common.exception.BusinessException;
import br.gov.sigefp.common.exception.ResourceNotFoundException;
import br.gov.sigefp.treasury.api.dto.ApprovalDTO;
import br.gov.sigefp.treasury.api.dto.PaymentAuthorizationDTO;
import br.gov.sigefp.treasury.api.dto.CreatePaymentAuthorizationDTO;
import br.gov.sigefp.treasury.domain.Approval;
import br.gov.sigefp.treasury.domain.PaymentAuthorization;
import br.gov.sigefp.treasury.repository.ApprovalRepository;
import br.gov.sigefp.treasury.repository.PaymentAuthorizationRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Serviço de aplicação para gestão de autorizações de pagamento.
* Gerencia workflow hierárquico de aprovação.
*/
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class PaymentAuthorizationService {
private final PaymentAuthorizationRepository authorizationRepository;
private final ApprovalRepository approvalRepository;
private final TreasuryPlanService treasuryPlanService;
private final br.gov.sigefp.treasury.service.PaymentOrderService paymentOrderService;
public PaymentAuthorizationDTO requestAuthorization(CreatePaymentAuthorizationDTO dto) {
PaymentAuthorization authorization = PaymentAuthorization.builder()
.paymentOrderId(dto.getPaymentOrderId())
.paymentBatchId(dto.getPaymentBatchId())
.requestedBy(dto.getRequestedBy())
.requestedAt(LocalDateTime.now())
.requiredApprovalLevel(dto.getRequiredApprovalLevel())
.currentApprovalLevel(1)
.status("PENDING")
.build();
PaymentAuthorization saved = authorizationRepository.save(authorization);
log.info("Autorização solicitada: id={}, requiredLevel={}, paymentOrderId={}",
saved.getId(), saved.getRequiredApprovalLevel(), saved.getPaymentOrderId());
return toDTO(saved);
}
public PaymentAuthorizationDTO approve(UUID authorizationId, UUID approverId, String comments) {
PaymentAuthorization authorization = authorizationRepository.findById(authorizationId)
.orElseThrow(() -> new ResourceNotFoundException(
"Autorização não encontrada: " + authorizationId));
if (!"PENDING".equals(authorization.getStatus()) &&
!"PARTIALLY_APPROVED".equals(authorization.getStatus())) {
throw new BusinessException(
"Apenas autorizações pendentes ou parcialmente aprovadas podem ser aprovadas",
"INVALID_STATUS",
org.springframework.http.HttpStatus.CONFLICT);
}
// Registrar aprovação
Approval approval = Approval.builder()
.authorization(authorization)
.level(authorization.getCurrentApprovalLevel())
.approvedBy(approverId)
.approvedAt(LocalDateTime.now())
.comments(comments)
.build();
approvalRepository.save(approval);
authorization.getApprovals().add(approval);
// Verificar se precisa de mais aprovações
if (authorization.getCurrentApprovalLevel() >= authorization.getRequiredApprovalLevel()) {
// INTEGRAÇÃO TESOURO (Fase 3.1): Validar Teto Financeiro antes de Aprovar
br.gov.sigefp.treasury.api.dto.PaymentOrderDTO paymentOrder = paymentOrderService
.findById(authorization.getPaymentOrderId());
try {
// 1. Validar disponibilidade
treasuryPlanService.validateAvailability(paymentOrder.getGrossAmount());
// 2. Consumir cota (Atualizar valor executado)
br.gov.sigefp.treasury.api.dto.TreasuryPlanDTO activePlan = treasuryPlanService
.findActivePlanForDate(java.time.LocalDate.now());
if (activePlan != null) {
treasuryPlanService.updateExecutedAmount(activePlan.getId(), paymentOrder.getGrossAmount());
}
authorization.setStatus("APPROVED");
log.info("Autorização totalmente aprovada e teto consumido: id={}, planId={}, amount={}",
authorizationId, activePlan != null ? activePlan.getId() : "N/A",
paymentOrder.getGrossAmount());
} catch (BusinessException e) {
if ("CEILING_EXCEEDED".equals(e.getCode())) {
log.warn("Autorização rejeitada por falta de teto: id={}, amount={}", authorizationId,
paymentOrder.getGrossAmount());
throw e; // Repassa a exceção para o controller
}
throw e;
}
} else {
authorization.setCurrentApprovalLevel(authorization.getCurrentApprovalLevel() + 1);
authorization.setStatus("PARTIALLY_APPROVED");
log.info("Autorização parcialmente aprovada: id={}, currentLevel={}",
authorizationId, authorization.getCurrentApprovalLevel());
}
PaymentAuthorization saved = authorizationRepository.save(authorization);
return toDTO(saved);
}
public PaymentAuthorizationDTO reject(UUID authorizationId, UUID approverId, String reason) {
PaymentAuthorization authorization = authorizationRepository.findById(authorizationId)
.orElseThrow(() -> new ResourceNotFoundException(
"Autorização não encontrada: " + authorizationId));
if ("APPROVED".equals(authorization.getStatus()) ||
"REJECTED".equals(authorization.getStatus())) {
throw new BusinessException(
"Autorização já foi finalizada",
"ALREADY_FINALIZED",
org.springframework.http.HttpStatus.CONFLICT);
}
authorization.setStatus("REJECTED");
authorization.setRejectionReason(reason);
PaymentAuthorization saved = authorizationRepository.save(authorization);
log.info("Autorização rejeitada: id={}, reason={}", authorizationId, reason);
return toDTO(saved);
}
@Transactional(readOnly = true)
public PaymentAuthorizationDTO findById(UUID id) {
PaymentAuthorization authorization = authorizationRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Autorização não encontrada: " + id));
return toDTO(authorization);
}
@Transactional(readOnly = true)
public List<PaymentAuthorizationDTO> findPendingApprovals(UUID approverId) {
// Retorna todas as autorizações pendentes que o aprovador pode aprovar
// (assumindo que o aprovador pode aprovar até o nível máximo)
return authorizationRepository.findPendingApprovals(Integer.MAX_VALUE).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
@Transactional(readOnly = true)
public List<PaymentAuthorizationDTO> findByPaymentOrderId(UUID paymentOrderId) {
return authorizationRepository.findByPaymentOrderId(paymentOrderId).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
@Transactional(readOnly = true)
public List<PaymentAuthorizationDTO> findByPaymentBatchId(UUID paymentBatchId) {
return authorizationRepository.findByPaymentBatchId(paymentBatchId).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
/**
* Calcula o nível de aprovação necessário baseado no valor do pagamento.
*/
public Integer calculateRequiredLevel(java.math.BigDecimal amount) {
// Valores até 100.000 XOF: 1 nível
// Valores 100.001 - 500.000 XOF: 2 níveis
// Valores acima de 500.000 XOF: 3 níveis
if (amount.compareTo(new java.math.BigDecimal("100000")) <= 0) {
return 1;
} else if (amount.compareTo(new java.math.BigDecimal("500000")) <= 0) {
return 2;
} else {
return 3;
}
}
private PaymentAuthorizationDTO toDTO(PaymentAuthorization authorization) {
List<ApprovalDTO> approvals = authorization.getApprovals().stream()
.map(this::approvalToDTO)
.collect(Collectors.toList());
return PaymentAuthorizationDTO.builder()
.id(authorization.getId())
.paymentOrderId(authorization.getPaymentOrderId())
.paymentBatchId(authorization.getPaymentBatchId())
.requestedBy(authorization.getRequestedBy())
.requestedAt(authorization.getRequestedAt())
.requiredApprovalLevel(authorization.getRequiredApprovalLevel())
.currentApprovalLevel(authorization.getCurrentApprovalLevel())
.status(authorization.getStatus())
.rejectionReason(authorization.getRejectionReason())
.approvals(approvals)
.createdAt(authorization.getCreatedAt())
.updatedAt(authorization.getUpdatedAt())
.build();
}
private ApprovalDTO approvalToDTO(Approval approval) {
return ApprovalDTO.builder()
.id(approval.getId())
.authorizationId(approval.getAuthorization().getId())
.level(approval.getLevel())
.approvedBy(approval.getApprovedBy())
.approvedAt(approval.getApprovedAt())
.comments(approval.getComments())
.signatureHash(approval.getSignatureHash())
.build();
}
}
@@ -0,0 +1,112 @@
package br.gov.sigefp.treasury.service;
import br.gov.sigefp.treasury.api.dto.CreatePaymentBatchDTO;
import br.gov.sigefp.treasury.api.dto.PaymentBatchDTO;
import br.gov.sigefp.treasury.domain.PaymentBatch;
import br.gov.sigefp.treasury.repository.PaymentBatchRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Serviço de aplicação para gestão de lotes de pagamento.
*/
@Service
@RequiredArgsConstructor
@Transactional
public class PaymentBatchService {
private final PaymentBatchRepository paymentBatchRepository;
public PaymentBatchDTO create(CreatePaymentBatchDTO dto) {
PaymentBatch paymentBatch = PaymentBatch.builder()
.periodId(dto.getPeriodId())
.ministryId(dto.getMinistryId())
.status("CREATED")
.build();
PaymentBatch saved = paymentBatchRepository.save(paymentBatch);
return toDTO(saved);
}
public PaymentBatchDTO updateStatus(UUID id, String status) {
PaymentBatch paymentBatch = paymentBatchRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Lote de pagamento não encontrado: " + id));
paymentBatch.setStatus(status);
PaymentBatch saved = paymentBatchRepository.save(paymentBatch);
return toDTO(saved);
}
@Transactional(readOnly = true)
public PaymentBatchDTO findById(UUID id) {
PaymentBatch paymentBatch = paymentBatchRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Lote de pagamento não encontrado: " + id));
return toDTO(paymentBatch);
}
@Transactional(readOnly = true)
public Page<PaymentBatchDTO> findAll(Pageable pageable) {
return paymentBatchRepository.findAll(pageable).map(this::toDTO);
}
@Transactional(readOnly = true)
public List<PaymentBatchDTO> findByPeriodId(Long periodId) {
return paymentBatchRepository.findByPeriodId(periodId).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
@Transactional(readOnly = true)
public List<PaymentBatchDTO> findByMinistryId(UUID ministryId) {
return paymentBatchRepository.findByMinistryId(ministryId).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
@Transactional(readOnly = true)
public List<PaymentBatchDTO> findByStatus(String status) {
return paymentBatchRepository.findByStatus(status).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
@Transactional(readOnly = true)
public List<PaymentBatchDTO> findByPeriodIdAndMinistryId(Long periodId, UUID ministryId) {
return paymentBatchRepository.findByPeriodIdAndMinistryId(periodId, ministryId).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
@Transactional(readOnly = true)
public List<PaymentBatchDTO> findByPeriodIdAndStatus(Long periodId, String status) {
return paymentBatchRepository.findByPeriodIdAndStatus(periodId, status).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
@Transactional(readOnly = true)
public List<PaymentBatchDTO> findByMinistryIdAndStatus(UUID ministryId, String status) {
return paymentBatchRepository.findByMinistryIdAndStatus(ministryId, status).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
private PaymentBatchDTO toDTO(PaymentBatch paymentBatch) {
return PaymentBatchDTO.builder()
.id(paymentBatch.getId())
.periodId(paymentBatch.getPeriodId())
.ministryId(paymentBatch.getMinistryId())
.createdAt(paymentBatch.getCreatedAt())
.createdBy(paymentBatch.getCreatedBy())
.status(paymentBatch.getStatus())
.build();
}
}
@@ -0,0 +1,288 @@
package br.gov.sigefp.treasury.service;
import br.gov.sigefp.rh.domain.AgentBankAccount;
import br.gov.sigefp.rh.domain.PayrollItem;
import br.gov.sigefp.rh.domain.PayrollRun;
import br.gov.sigefp.rh.repository.AgentBankAccountRepository;
import br.gov.sigefp.rh.repository.PayrollItemRepository;
import br.gov.sigefp.rh.repository.PayrollRunRepository;
import br.gov.sigefp.treasury.api.dto.CreatePaymentOrderDTO;
import br.gov.sigefp.treasury.api.dto.PaymentOrderDTO;
import br.gov.sigefp.treasury.domain.PaymentBatch;
import br.gov.sigefp.treasury.domain.PaymentOrder;
import br.gov.sigefp.treasury.repository.PaymentBatchRepository;
import br.gov.sigefp.treasury.repository.PaymentOrderRepository;
import br.gov.sigefp.common.exception.BusinessException;
import br.gov.sigefp.common.exception.ResourceNotFoundException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Serviço de aplicação para gestão de ordens de pagamento.
*/
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class PaymentOrderService implements br.gov.sigefp.common.service.PaymentGenerator {
private final PaymentOrderRepository paymentOrderRepository;
private final PaymentBatchRepository paymentBatchRepository;
private final PayrollRunRepository payrollRunRepository;
private final PayrollItemRepository payrollItemRepository;
private final AgentBankAccountRepository agentBankAccountRepository;
/**
* Gerar ordens de pagamento a partir de um payrollRun.
* Cria uma PaymentOrder para cada agente com itens de folha (EARNING).
*
* @param payrollRunId ID da execução de folha (UUID)
* @param paymentBatchId ID do lote de pagamento (opcional). Se null, cria novo
* lote.
*/
@Override
public void generateOrdersFromPayrollRun(UUID payrollRunId, UUID paymentBatchId) {
// Implementação adaptada para void (conforme interface)
generateOrdersInternal(payrollRunId, paymentBatchId);
}
/**
* Método interno que retorna a lista (para uso local ou testes)
*/
public List<PaymentOrderDTO> generateOrdersInternal(UUID payrollRunId, UUID paymentBatchId) {
PayrollRun payrollRun = payrollRunRepository.findById(payrollRunId)
.orElseThrow(() -> new ResourceNotFoundException(
"Execução de folha não encontrada: " + payrollRunId));
if (!"COMPLETED".equals(payrollRun.getStatus())) {
throw new BusinessException(
"Apenas execuções de folha com status COMPLETED podem gerar ordens de pagamento",
"INVALID_RUN_STATUS", org.springframework.http.HttpStatus.PRECONDITION_FAILED);
}
PaymentBatch paymentBatch;
if (paymentBatchId != null) {
paymentBatch = paymentBatchRepository.findById(paymentBatchId)
.orElseThrow(() -> new ResourceNotFoundException(
"Lote de pagamento não encontrado: " + paymentBatchId));
} else {
// Auto-create PaymentBatch
String batchRef = String.format("FOLHA-%d/%d", payrollRun.getPeriod().getMonth(),
payrollRun.getPeriod().getFiscalYear());
paymentBatch = PaymentBatch.builder()
.reference(batchRef)
.description("Folha de Pagamento Automática - " + payrollRun.getOrgUnit()) // Simplificado
.status("PENDING")
.createdAt(LocalDateTime.now())
.build();
paymentBatch = paymentBatchRepository.save(paymentBatch);
log.info("Lote de pagamento criado automaticamente: {}", paymentBatch.getId());
}
List<PayrollItem> items = payrollItemRepository.findByPayrollRunId(payrollRunId);
// Filtrar apenas itens do tipo EARNING (proventos)
List<PayrollItem> earningItems = items.stream()
.filter(item -> "EARNING".equals(item.getLineType()))
.collect(Collectors.toList());
if (earningItems.isEmpty()) {
log.warn("Nenhum item de provento encontrado na execução de folha: payrollRunId={}",
payrollRunId);
return List.of();
}
// Agrupar itens por agente e calcular totais
Map<UUID, List<PayrollItem>> itemsByAgent = earningItems.stream()
.collect(Collectors.groupingBy(PayrollItem::getAgent));
List<PaymentOrderDTO> createdOrders = new ArrayList<>();
for (Map.Entry<UUID, List<PayrollItem>> entry : itemsByAgent.entrySet()) {
UUID agentId = entry.getKey();
List<PayrollItem> agentItems = entry.getValue();
// Calcular valores brutos e líquidos
BigDecimal grossAmount = agentItems.stream()
.map(item -> item.getTotalAmount() != null ? item.getTotalAmount()
: BigDecimal.ZERO)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// Calcular descontos (itens do tipo DEDUCTION)
List<PayrollItem> deductionItems = items.stream()
.filter(item -> agentId.equals(item.getAgent()))
.filter(item -> "DEDUCTION".equals(item.getLineType()))
.collect(Collectors.toList());
BigDecimal totalDeductions = deductionItems.stream()
.map(item -> item.getTotalAmount() != null ? item.getTotalAmount()
: BigDecimal.ZERO)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// Calcular Imposto Retido (Deduções com Classificação Econômica Iniciada em "7"
// - Receita)
BigDecimal taxAmount = deductionItems.stream()
.filter(item -> item.getDeductionType() != null &&
item.getDeductionType().getEconomicClassCode() != null &&
item.getDeductionType().getEconomicClassCode().startsWith("7"))
.map(item -> item.getTotalAmount() != null ? item.getTotalAmount()
: BigDecimal.ZERO)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal netAmount = grossAmount.subtract(totalDeductions);
// Buscar conta bancária principal do agente
AgentBankAccount primaryAccount = agentBankAccountRepository
.findByAgentIdAndIsPrimaryTrue(agentId)
.orElse(null);
if (primaryAccount == null) {
log.warn("Agente sem conta bancária principal: agentId={}, payrollRunId={}", agentId,
payrollRunId);
// Continuar sem conta bancária - pode ser preenchida depois
}
// Usar UUID nativo
UUID payrollRunIdVal = payrollRunId;
// Buscar budgetLineId do primeiro item do agente (assumindo que todos os itens
// do agente
// usam a mesma linha orçamentária, ou pegar a primeira encontrada)
UUID budgetLineId = agentItems.stream()
.filter(item -> item.getBudgetLine() != null)
.map(PayrollItem::getBudgetLine)
.findFirst()
.orElse(null);
// Logica de Integridade Fiscal (Tax Retention)
String retentionType = taxAmount.compareTo(BigDecimal.ZERO) > 0 ? "AUTOMATIC_RETENTION"
: "NONE";
// TODO: Buscar conta de arrecadação DGI via parâmetro
UUID taxCollectionAccount = null;
// Criar ordem de pagamento
PaymentOrder paymentOrder = PaymentOrder.builder()
.paymentBatch(paymentBatch)
.payrollRunId(payrollRunIdVal)
.agentId(agentId)
.bankAccountId(primaryAccount != null ? primaryAccount.getId() : null)
.budgetLineId(budgetLineId)
.grossAmount(grossAmount)
.netAmount(netAmount)
.taxAmount(taxAmount)
.taxRetentionType(retentionType)
.taxCollectionAccountId(taxCollectionAccount)
.status("CREATED")
.build();
PaymentOrder saved = paymentOrderRepository.save(paymentOrder);
createdOrders.add(toDTO(saved));
log.info("Ordem criada com Integridade Fiscal: orderId={}, net={}, tax={}",
saved.getId(), netAmount, taxAmount);
}
log.info("Total de ordens de pagamento criadas: {} para payrollRunId={}",
createdOrders.size(), payrollRunId);
// Atualizar totais do lote
// (Opcional, mas boa prática)
return createdOrders;
}
public PaymentOrderDTO create(CreatePaymentOrderDTO dto) {
PaymentBatch paymentBatch = paymentBatchRepository.findById(dto.getPaymentBatchId())
.orElseThrow(() -> new ResourceNotFoundException(
"Lote de pagamento não encontrado: " + dto.getPaymentBatchId()));
PaymentOrder paymentOrder = PaymentOrder.builder()
.paymentBatch(paymentBatch)
.payrollRunId(dto.getPayrollRunId())
.agentId(dto.getAgentId())
.bankAccountId(dto.getBankAccountId())
.grossAmount(dto.getGrossAmount())
.netAmount(dto.getNetAmount())
.taxAmount(dto.getTaxAmount() != null ? dto.getTaxAmount() : BigDecimal.ZERO)
.taxRetentionType(dto.getTaxRetentionType())
.taxCollectionAccountId(dto.getTaxCollectionAccountId())
.status("CREATED")
.build();
PaymentOrder saved = paymentOrderRepository.save(paymentOrder);
return toDTO(saved);
}
public PaymentOrderDTO updateStatus(UUID id, String status) {
PaymentOrder paymentOrder = paymentOrderRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException(
"Ordem de pagamento não encontrada: " + id));
paymentOrder.setStatus(status);
PaymentOrder saved = paymentOrderRepository.save(paymentOrder);
return toDTO(saved);
}
@Transactional(readOnly = true)
public PaymentOrderDTO findById(UUID id) {
PaymentOrder paymentOrder = paymentOrderRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException(
"Ordem de pagamento não encontrada: " + id));
return toDTO(paymentOrder);
}
@Transactional(readOnly = true)
public Page<PaymentOrderDTO> findAll(Pageable pageable) {
return paymentOrderRepository.findAll(pageable).map(this::toDTO);
}
@Transactional(readOnly = true)
public List<PaymentOrderDTO> findByPaymentBatchId(UUID batchId) {
return paymentOrderRepository.findByPaymentBatchId(batchId).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
@Transactional(readOnly = true)
public List<PaymentOrderDTO> findByStatus(String status) {
return paymentOrderRepository.findByStatus(status).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
@Transactional(readOnly = true)
public List<PaymentOrderDTO> findByPaymentBatchIdAndStatus(UUID batchId, String status) {
return paymentOrderRepository.findByPaymentBatchIdAndStatus(batchId, status).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
private PaymentOrderDTO toDTO(PaymentOrder paymentOrder) {
return PaymentOrderDTO.builder()
.id(paymentOrder.getId())
.paymentBatchId(paymentOrder.getPaymentBatch().getId())
.payrollRunId(paymentOrder.getPayrollRunId())
.agentId(paymentOrder.getAgentId())
.bankAccountId(paymentOrder.getBankAccountId())
.budgetLineId(paymentOrder.getBudgetLineId())
.grossAmount(paymentOrder.getGrossAmount())
.netAmount(paymentOrder.getNetAmount())
.taxAmount(paymentOrder.getTaxAmount())
.taxRetentionType(paymentOrder.getTaxRetentionType())
.taxCollectionAccountId(paymentOrder.getTaxCollectionAccountId())
.status(paymentOrder.getStatus())
.build();
}
}
@@ -0,0 +1,183 @@
package br.gov.sigefp.treasury.service;
import br.gov.sigefp.treasury.domain.CashAccount;
import br.gov.sigefp.treasury.domain.TreasuryEntry;
import br.gov.sigefp.treasury.domain.TreasuryEntryType;
import br.gov.sigefp.treasury.repository.CashAccountRepository;
import br.gov.sigefp.treasury.repository.TreasuryEntryRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* Serviço para nivelamento automático (sweeping) de contas de trânsito.
* Conforme Master Plan - Module 3: Unified Account Structure (CUT).
*
* Regra de Ouro UEMOA: Se o saldo na Conta de Trânsito > 0 no fim do dia,
* o sistema deve transferir automaticamente para a CUT no BCEAO.
*/
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class SweepingService {
private final CashAccountRepository cashAccountRepository;
private final TreasuryEntryRepository treasuryEntryRepository;
private final CashAccountService cashAccountService;
/**
* Job agendado para executar nivelamento diário às 17:00.
* Conforme Master Plan: @Scheduled(cron = "0 0 17 * * ?")
*/
@Scheduled(cron = "0 0 17 * * ?") // 5 PM Daily
public void performDailySweeping() {
log.info("Iniciando nivelamento diário de contas de trânsito...");
// Encontrar todas as contas de trânsito com saldo > 0
List<CashAccount> transitAccounts = cashAccountRepository.findAll().stream()
.filter(account -> "TRANSIT".equals(account.getCategory()))
.filter(account -> account.getCurrentBalance().compareTo(BigDecimal.ZERO) > 0)
.filter(CashAccount::getIsActive)
.toList();
if (transitAccounts.isEmpty()) {
log.info("Nenhuma conta de trânsito com saldo encontrada para nivelamento");
return;
}
int sweptCount = 0;
BigDecimal totalSwept = BigDecimal.ZERO;
for (CashAccount transitAccount : transitAccounts) {
try {
BigDecimal balance = transitAccount.getCurrentBalance();
// Verificar se tem conta pai (CUT)
if (transitAccount.getParentId() == null) {
log.warn("Conta de trânsito sem conta pai (CUT): accountId={}, code={}",
transitAccount.getId(), transitAccount.getCode());
continue;
}
CashAccount parentAccount = cashAccountRepository.findById(transitAccount.getParentId())
.orElse(null);
if (parentAccount == null) {
log.warn("Conta pai (CUT) não encontrada: parentId={}, transitAccount={}",
transitAccount.getParentId(), transitAccount.getCode());
continue;
}
// Realizar transferência (sweeping)
sweepAccount(transitAccount, parentAccount, balance);
sweptCount++;
totalSwept = totalSwept.add(balance);
log.info("Conta nivelada: transitAccount={}, balance={}, parentAccount={}",
transitAccount.getCode(), balance, parentAccount.getCode());
} catch (Exception e) {
log.error("Erro ao nivelar conta de trânsito: accountId={}, code={}, error={}",
transitAccount.getId(), transitAccount.getCode(), e.getMessage(), e);
}
}
log.info("Nivelamento diário concluído: {} contas niveladas, total transferido: {}",
sweptCount, totalSwept);
}
/**
* Realiza o nivelamento de uma conta de trânsito para a conta pai (CUT).
*/
public void sweepAccount(CashAccount transitAccount, CashAccount parentAccount, BigDecimal amount) {
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Valor do nivelamento deve ser positivo");
}
if (transitAccount.getCurrentBalance().subtract(amount).compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalStateException(
String.format("Saldo insuficiente na conta de trânsito. Disponível: %s, Solicitado: %s",
transitAccount.getCurrentBalance(), amount));
}
LocalDate today = LocalDate.now();
// Criar entrada de tesouraria para a conta de trânsito (saída)
TreasuryEntry transitEntry = TreasuryEntry.builder()
.cashAccount(transitAccount)
.type(TreasuryEntryType.CASH_WITHDRAWAL)
.amount(amount)
.transactionDate(today)
.documentReference("Nivelamento Automático - " + today)
.description(String.format("Nivelamento automático para CUT: %s",
parentAccount.getCode()))
.status("EXECUTED")
.build();
// Criar entrada de tesouraria para a conta pai (entrada)
TreasuryEntry parentEntry = TreasuryEntry.builder()
.cashAccount(parentAccount)
.type(TreasuryEntryType.CASH_DEPOSIT)
.amount(amount)
.transactionDate(today)
.documentReference("Nivelamento Automático - " + today)
.description(String.format("Nivelamento recebido de: %s", transitAccount.getCode()))
.status("EXECUTED")
.build();
treasuryEntryRepository.save(transitEntry);
treasuryEntryRepository.save(parentEntry);
// Atualizar saldos
cashAccountService.updateBalance(transitAccount.getId(), amount, "OUTFLOW");
cashAccountService.updateBalance(parentAccount.getId(), amount, "INFLOW");
log.info("Nivelamento executado: {} transferido de {} para {}",
amount, transitAccount.getCode(), parentAccount.getCode());
}
/**
* Executa nivelamento manual de uma conta específica.
*/
public void sweepAccountManually(UUID transitAccountId) {
CashAccount transitAccount = cashAccountRepository.findById(transitAccountId)
.orElseThrow(() -> new br.gov.sigefp.common.exception.ResourceNotFoundException(
"Conta de trânsito não encontrada: " + transitAccountId));
if (!"TRANSIT".equals(transitAccount.getCategory())) {
throw new br.gov.sigefp.common.exception.BusinessException(
"A conta especificada não é uma conta de trânsito",
"INVALID_ACCOUNT_TYPE",
org.springframework.http.HttpStatus.BAD_REQUEST);
}
BigDecimal balance = transitAccount.getCurrentBalance();
if (balance.compareTo(BigDecimal.ZERO) <= 0) {
log.info("Conta de trânsito sem saldo para nivelar: accountId={}", transitAccountId);
return;
}
if (transitAccount.getParentId() == null) {
throw new br.gov.sigefp.common.exception.BusinessException(
"Conta de trânsito sem conta pai (CUT) configurada",
"MISSING_PARENT_ACCOUNT",
org.springframework.http.HttpStatus.BAD_REQUEST);
}
CashAccount parentAccount = cashAccountRepository.findById(transitAccount.getParentId())
.orElseThrow(() -> new br.gov.sigefp.common.exception.ResourceNotFoundException(
"Conta pai (CUT) não encontrada: " + transitAccount.getParentId()));
sweepAccount(transitAccount, parentAccount, balance);
}
}
@@ -0,0 +1,160 @@
package br.gov.sigefp.treasury.service;
import br.gov.sigefp.common.exception.BusinessException;
import br.gov.sigefp.common.exception.ResourceNotFoundException;
import br.gov.sigefp.treasury.api.dto.CreateTreasuryEntryDTO;
import br.gov.sigefp.treasury.api.dto.TreasuryEntryDTO;
import br.gov.sigefp.treasury.domain.CashAccount;
import br.gov.sigefp.treasury.domain.TreasuryEntry;
import br.gov.sigefp.treasury.domain.TreasuryEntryType;
import br.gov.sigefp.treasury.repository.CashAccountRepository;
import br.gov.sigefp.treasury.repository.TreasuryEntryRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Serviço de aplicação para gestão de entradas de tesouraria.
* Similar a BudgetEntryService no módulo de Orçamento.
*/
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class TreasuryEntryService {
private final TreasuryEntryRepository treasuryEntryRepository;
private final CashAccountRepository cashAccountRepository;
private final CashAccountService cashAccountService;
public TreasuryEntryDTO create(CreateTreasuryEntryDTO dto) {
// Validar conta de caixa
CashAccount cashAccount = cashAccountRepository.findById(dto.getCashAccountId())
.orElseThrow(() -> new ResourceNotFoundException(
"Conta de caixa não encontrada: " + dto.getCashAccountId()));
// Validar disponibilidade de caixa para saídas
if (isOutflowType(dto.getType()) && dto.getAmount() != null) {
BigDecimal available = cashAccountService.getAvailableBalance(dto.getCashAccountId());
if (dto.getAmount().compareTo(available) > 0) {
throw new BusinessException(
String.format("Saldo disponível insuficiente. Disponível: %s, Solicitado: %s",
available, dto.getAmount()),
"INSUFFICIENT_CASH",
org.springframework.http.HttpStatus.CONFLICT);
}
}
TreasuryEntry entry = TreasuryEntry.builder()
.cashAccount(cashAccount)
.type(dto.getType())
.amount(dto.getAmount())
.transactionDate(dto.getTransactionDate())
.documentReference(dto.getDocumentReference())
.description(dto.getDescription())
.status(dto.getStatus() != null ? dto.getStatus() : "DRAFT")
.approvalLevel(dto.getApprovalLevel())
.paymentOrderId(dto.getPaymentOrderId())
.paymentBatchId(dto.getPaymentBatchId())
.budgetLineId(dto.getBudgetLineId())
.build();
TreasuryEntry saved = treasuryEntryRepository.save(entry);
// Atualizar saldo da conta se necessário
updateCashAccountBalance(saved);
log.info("Entrada de tesouraria criada: id={}, type={}, amount={}",
saved.getId(), saved.getType(), saved.getAmount());
return toDTO(saved);
}
@Transactional(readOnly = true)
public TreasuryEntryDTO findById(UUID id) {
TreasuryEntry entry = treasuryEntryRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Entrada não encontrada: " + id));
return toDTO(entry);
}
@Transactional(readOnly = true)
public Page<TreasuryEntryDTO> findByCashAccountId(UUID cashAccountId, Pageable pageable) {
return treasuryEntryRepository.findByCashAccountId(cashAccountId, pageable)
.map(this::toDTO);
}
@Transactional(readOnly = true)
public Page<TreasuryEntryDTO> findByType(TreasuryEntryType type, Pageable pageable) {
return treasuryEntryRepository.findByType(type, pageable)
.map(this::toDTO);
}
@Transactional(readOnly = true)
public Page<TreasuryEntryDTO> findByStatus(String status, Pageable pageable) {
return treasuryEntryRepository.findByStatus(status, pageable)
.map(this::toDTO);
}
@Transactional(readOnly = true)
public BigDecimal calculateAvailableBalance(UUID cashAccountId) {
return cashAccountService.getAvailableBalance(cashAccountId);
}
/**
* Atualiza o saldo da conta de caixa baseado no tipo de entrada.
*/
private void updateCashAccountBalance(TreasuryEntry entry) {
if (entry.getStatus().equals("EXECUTED")) {
if (isOutflowType(entry.getType())) {
cashAccountService.updateBalance(entry.getCashAccount().getId(), entry.getAmount(), "OUTFLOW");
} else if (isInflowType(entry.getType())) {
cashAccountService.updateBalance(entry.getCashAccount().getId(), entry.getAmount(), "INFLOW");
}
} else if (entry.getStatus().equals("APPROVED") && isOutflowType(entry.getType())) {
// Comprometer saldo quando aprovado
cashAccountService.commitBalance(entry.getCashAccount().getId(), entry.getAmount());
}
}
private boolean isOutflowType(TreasuryEntryType type) {
return type == TreasuryEntryType.PAYMENT_EXECUTION ||
type == TreasuryEntryType.BATCH_EXECUTION ||
type == TreasuryEntryType.CASH_WITHDRAWAL ||
type == TreasuryEntryType.PAYMENT_CANCELLATION;
}
private boolean isInflowType(TreasuryEntryType type) {
return type == TreasuryEntryType.CASH_DEPOSIT ||
type == TreasuryEntryType.BANK_AVAILABILITY ||
type == TreasuryEntryType.CASH_AVAILABILITY;
}
private TreasuryEntryDTO toDTO(TreasuryEntry entry) {
return TreasuryEntryDTO.builder()
.id(entry.getId())
.type(entry.getType())
.amount(entry.getAmount())
.transactionDate(entry.getTransactionDate())
.documentReference(entry.getDocumentReference())
.description(entry.getDescription())
.status(entry.getStatus())
.approvalLevel(entry.getApprovalLevel())
.approvedBy(entry.getApprovedBy())
.approvedAt(entry.getApprovedAt())
.cashAccountId(entry.getCashAccount().getId())
.paymentOrderId(entry.getPaymentOrderId())
.paymentBatchId(entry.getPaymentBatchId())
.budgetLineId(entry.getBudgetLineId())
.createdAt(entry.getCreatedAt())
.updatedAt(entry.getUpdatedAt())
.build();
}
}
@@ -0,0 +1,165 @@
package br.gov.sigefp.treasury.service;
import br.gov.sigefp.budget.integration.BudgetIntegrationService;
import br.gov.sigefp.rh.domain.PayrollItem;
import br.gov.sigefp.rh.domain.PayrollRun;
import br.gov.sigefp.rh.repository.PayrollItemRepository;
import br.gov.sigefp.rh.repository.PayrollRunRepository;
import br.gov.sigefp.treasury.api.dto.CreateTreasuryPaymentDTO;
import br.gov.sigefp.treasury.api.dto.TreasuryPaymentDTO;
import br.gov.sigefp.treasury.domain.PaymentOrder;
import br.gov.sigefp.treasury.domain.TreasuryPayment;
import br.gov.sigefp.treasury.repository.PaymentOrderRepository;
import br.gov.sigefp.treasury.repository.TreasuryPaymentRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Serviço de aplicação para gestão de pagamentos efetivados pela tesouraria.
*/
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class TreasuryPaymentService {
private final TreasuryPaymentRepository treasuryPaymentRepository;
private final PaymentOrderRepository paymentOrderRepository;
private final BudgetIntegrationService budgetIntegrationService;
/**
* Registrar confirmação de pagamento (paidAt, transactionRef, status, message).
*/
public TreasuryPaymentDTO registerPayment(CreateTreasuryPaymentDTO dto) {
PaymentOrder paymentOrder = paymentOrderRepository.findById(dto.getPaymentOrderId())
.orElseThrow(() -> new IllegalArgumentException(
"Ordem de pagamento não encontrada: " + dto.getPaymentOrderId()));
// 1. Perna Líquida (Vendor)
TreasuryPayment netPayment = TreasuryPayment.builder()
.paymentOrder(paymentOrder)
.paidAt(dto.getPaidAt() != null ? dto.getPaidAt() : Instant.now())
.transactionRef(dto.getTransactionRef())
.status(dto.getStatus())
.message(dto.getMessage() != null ? dto.getMessage() : "Pagamento Líquido ao Beneficiário")
.build();
TreasuryPayment savedNet = treasuryPaymentRepository.save(netPayment);
log.info("Pagamento Líquido registrado: id={}, amount={}", savedNet.getId(), paymentOrder.getNetAmount());
// 2. Perna Fiscal (Integridade RN03) - Apenas se houver imposto retido e status
// for PAID
if ("PAID".equals(dto.getStatus()) && paymentOrder.getTaxAmount() != null &&
paymentOrder.getTaxAmount().compareTo(java.math.BigDecimal.ZERO) > 0) {
TreasuryPayment taxPayment = TreasuryPayment.builder()
.paymentOrder(paymentOrder)
.paidAt(dto.getPaidAt() != null ? dto.getPaidAt() : Instant.now())
.transactionRef(dto.getTransactionRef() + "-TAX")
.status("PAID")
.message("Retenção na Fonte: " + paymentOrder.getTaxRetentionType())
.build();
TreasuryPayment savedTax = treasuryPaymentRepository.save(taxPayment);
log.info("Retenção Fiscal registrada: id={}, amount={}, targetAccount={}",
savedTax.getId(), paymentOrder.getTaxAmount(), paymentOrder.getTaxCollectionAccountId());
}
// Atualizar status da ordem de pagamento se o pagamento foi confirmado
if ("PAID".equals(dto.getStatus())) {
paymentOrder.setStatus("PAID");
paymentOrderRepository.save(paymentOrder);
// Criar execução orçamentária do tipo PAYMENT
try {
if (paymentOrder.getBudgetLineId() != null && paymentOrder.getGrossAmount() != null) {
// Calcular periodId a partir da data do pagamento
LocalDate paymentDate = savedNet.getPaidAt().atZone(java.time.ZoneId.systemDefault()).toLocalDate();
YearMonth paymentPeriod = YearMonth.from(paymentDate);
Long periodId = BudgetIntegrationService.toPeriodId(
paymentPeriod.getYear(),
paymentPeriod.getMonthValue());
// Criar execução orçamentária do tipo PAYMENT
// IMPORTANTE: Execução Orçamentária deve refletir o VALOR BRUTO (Despesa Total)
budgetIntegrationService.createPaymentFromTreasury(
paymentOrder.getBudgetLineId(),
periodId,
paymentOrder.getGrossAmount(), // Alterado de netAmount para grossAmount
savedNet.getId());
log.info(
"Execução orçamentária (PAYMENT) criada a partir de pagamento: paymentOrderId={}, budgetLineId={}, grossAmount={}",
paymentOrder.getId(), paymentOrder.getBudgetLineId(), paymentOrder.getGrossAmount());
} else {
log.warn(
"Não foi possível criar execução orçamentária: paymentOrderId={}, budgetLineId={}, amount={}",
paymentOrder.getId(), paymentOrder.getBudgetLineId(), paymentOrder.getGrossAmount());
}
} catch (Exception e) {
log.error("Erro ao criar execução orçamentária a partir de pagamento: {}", e.getMessage(), e);
// Não falha o registro do pagamento se a execução orçamentária falhar
}
} else if ("REJECTED".equals(dto.getStatus())) {
paymentOrder.setStatus("REJECTED");
paymentOrderRepository.save(paymentOrder);
}
return toDTO(savedNet);
}
@Transactional(readOnly = true)
public TreasuryPaymentDTO findById(UUID id) {
TreasuryPayment treasuryPayment = treasuryPaymentRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Pagamento não encontrado: " + id));
return toDTO(treasuryPayment);
}
@Transactional(readOnly = true)
public Page<TreasuryPaymentDTO> findAll(Pageable pageable) {
return treasuryPaymentRepository.findAll(pageable).map(this::toDTO);
}
@Transactional(readOnly = true)
public List<TreasuryPaymentDTO> findByPaymentOrderId(UUID paymentOrderId) {
return treasuryPaymentRepository.findByPaymentOrderId(paymentOrderId).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
@Transactional(readOnly = true)
public List<TreasuryPaymentDTO> findByStatus(String status) {
return treasuryPaymentRepository.findByStatus(status).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
@Transactional(readOnly = true)
public List<TreasuryPaymentDTO> findByPaymentOrderIdAndStatus(UUID paymentOrderId, String status) {
return treasuryPaymentRepository.findByPaymentOrderIdAndStatus(paymentOrderId, status).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
private TreasuryPaymentDTO toDTO(TreasuryPayment treasuryPayment) {
return TreasuryPaymentDTO.builder()
.id(treasuryPayment.getId())
.paymentOrderId(treasuryPayment.getPaymentOrder().getId())
.paidAt(treasuryPayment.getPaidAt())
.transactionRef(treasuryPayment.getTransactionRef())
.status(treasuryPayment.getStatus())
.message(treasuryPayment.getMessage())
.build();
}
}
@@ -0,0 +1,221 @@
package br.gov.sigefp.treasury.service;
import br.gov.sigefp.common.exception.BusinessException;
import br.gov.sigefp.common.exception.ResourceNotFoundException;
import br.gov.sigefp.treasury.api.dto.CreateTreasuryPlanDTO;
import br.gov.sigefp.treasury.api.dto.TreasuryPlanDTO;
import br.gov.sigefp.treasury.domain.TreasuryPlan;
import br.gov.sigefp.treasury.repository.TreasuryPlanRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Serviço de aplicação para gestão de Planos de Tesouraria (PT).
* Conforme Master Plan - Module 2: Preventive Control.
*/
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class TreasuryPlanService {
private final TreasuryPlanRepository treasuryPlanRepository;
/**
* Cria um novo plano de tesouraria.
*/
public TreasuryPlanDTO createPlan(CreateTreasuryPlanDTO dto) {
// Verificar se já existe plano para o mesmo ano/mês
treasuryPlanRepository.findByFiscalYearAndReferenceMonth(
dto.getFiscalYear(), dto.getReferenceMonth())
.ifPresent(existing -> {
throw new BusinessException(
String.format("Já existe um plano para o ano %d, mês %d",
dto.getFiscalYear(), dto.getReferenceMonth()),
"DUPLICATE_PLAN",
org.springframework.http.HttpStatus.CONFLICT);
});
// Calcular datas se não fornecidas
LocalDate startDate = dto.getStartDate();
LocalDate endDate = dto.getEndDate();
if (startDate == null || endDate == null) {
YearMonth yearMonth = YearMonth.of(dto.getFiscalYear(), dto.getReferenceMonth());
startDate = yearMonth.atDay(1);
endDate = yearMonth.atEndOfMonth();
}
// Validar datas
if (endDate.isBefore(startDate)) {
throw new BusinessException(
"Data de fim deve ser posterior à data de início",
"INVALID_DATE_RANGE",
org.springframework.http.HttpStatus.BAD_REQUEST);
}
TreasuryPlan plan = TreasuryPlan.builder()
.fiscalYear(dto.getFiscalYear())
.referenceMonth(dto.getReferenceMonth())
.approvedCeiling(dto.getApprovedCeiling())
.executedAmount(BigDecimal.ZERO)
.startDate(startDate)
.endDate(endDate)
.status("DRAFT")
.build();
TreasuryPlan saved = treasuryPlanRepository.save(plan);
log.info("Plano de Tesouraria criado: id={}, fiscalYear={}, month={}, ceiling={}",
saved.getId(), saved.getFiscalYear(), saved.getReferenceMonth(), saved.getApprovedCeiling());
return toDTO(saved);
}
/**
* Aprova um plano de tesouraria.
*/
public TreasuryPlanDTO approvePlan(UUID planId, UUID approverId) {
TreasuryPlan plan = treasuryPlanRepository.findById(planId)
.orElseThrow(() -> new ResourceNotFoundException("Plano não encontrado: " + planId));
if (!"DRAFT".equals(plan.getStatus())) {
throw new BusinessException(
"Apenas planos em rascunho podem ser aprovados",
"INVALID_STATUS",
org.springframework.http.HttpStatus.CONFLICT);
}
// Fechar planos anteriores sobrepostos
List<TreasuryPlan> overlappingPlans = treasuryPlanRepository.findApprovedPlansForPeriod(
plan.getStartDate(), plan.getEndDate());
for (TreasuryPlan overlapping : overlappingPlans) {
if (overlapping.getStatus().equals("APPROVED")) {
overlapping.setStatus("CLOSED");
treasuryPlanRepository.save(overlapping);
log.info("Plano sobreposto fechado: id={}", overlapping.getId());
}
}
plan.setStatus("APPROVED");
plan.setApprovedBy(approverId);
plan.setApprovedAt(java.time.LocalDateTime.now());
TreasuryPlan saved = treasuryPlanRepository.save(plan);
log.info("Plano de Tesouraria aprovado: id={}, approvedBy={}", planId, approverId);
return toDTO(saved);
}
/**
* Valida disponibilidade de fundos conforme o plano ativo.
* Conforme Master Plan 2.3: Integration Point.
*
* @param amount Valor a ser validado
* @return true se há disponibilidade, false caso contrário
* @throws BusinessException se o teto for excedido
*/
public boolean validateAvailability(BigDecimal amount) {
LocalDate today = LocalDate.now();
TreasuryPlan activePlan = treasuryPlanRepository.findActivePlanForDate(today)
.orElse(null);
if (activePlan == null) {
log.warn("Nenhum plano ativo encontrado para a data: {}", today);
// Se não houver plano, permitir (pode ser configurável)
return true;
}
BigDecimal available = activePlan.getAvailableAmount();
if (amount.compareTo(available) > 0) {
throw new BusinessException(
String.format("Valor excede o teto disponível no Plano de Tesouraria. " +
"Disponível: %s, Solicitado: %s, Plano: %d/%d",
available, amount, activePlan.getFiscalYear(), activePlan.getReferenceMonth()),
"CEILING_EXCEEDED",
org.springframework.http.HttpStatus.CONFLICT);
}
return true;
}
/**
* Atualiza o valor executado do plano (chamado após autorização de pagamento).
*/
public void updateExecutedAmount(UUID planId, BigDecimal amount) {
TreasuryPlan plan = treasuryPlanRepository.findById(planId)
.orElseThrow(() -> new ResourceNotFoundException("Plano não encontrado: " + planId));
plan.setExecutedAmount(plan.getExecutedAmount().add(amount));
treasuryPlanRepository.save(plan);
log.debug("Valor executado atualizado: planId={}, newExecutedAmount={}",
planId, plan.getExecutedAmount());
}
/**
* Encontra o plano ativo para uma data específica.
*/
@Transactional(readOnly = true)
public TreasuryPlanDTO findActivePlanForDate(LocalDate date) {
return treasuryPlanRepository.findActivePlanForDate(date)
.map(this::toDTO)
.orElse(null);
}
@Transactional(readOnly = true)
public TreasuryPlanDTO findById(UUID id) {
TreasuryPlan plan = treasuryPlanRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Plano não encontrado: " + id));
return toDTO(plan);
}
@Transactional(readOnly = true)
public List<TreasuryPlanDTO> findByStatus(String status) {
return treasuryPlanRepository.findByStatus(status).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
/**
* Calcula o valor executado para um plano específico.
*/
@Transactional(readOnly = true)
public BigDecimal calculateExecutedAmount(UUID planId) {
TreasuryPlan plan = treasuryPlanRepository.findById(planId)
.orElseThrow(() -> new ResourceNotFoundException("Plano não encontrado: " + planId));
return treasuryPlanRepository.calculateExecutedAmountForPeriod(
plan.getStartDate(), plan.getEndDate());
}
private TreasuryPlanDTO toDTO(TreasuryPlan plan) {
return TreasuryPlanDTO.builder()
.id(plan.getId())
.fiscalYear(plan.getFiscalYear())
.referenceMonth(plan.getReferenceMonth())
.status(plan.getStatus())
.approvedCeiling(plan.getApprovedCeiling())
.executedAmount(plan.getExecutedAmount())
.availableAmount(plan.getAvailableAmount())
.startDate(plan.getStartDate())
.endDate(plan.getEndDate())
.approvedBy(plan.getApprovedBy())
.approvedAt(plan.getApprovedAt())
.createdAt(plan.getCreatedAt())
.updatedAt(plan.getUpdatedAt())
.build();
}
}
@@ -0,0 +1,17 @@
package br.gov.sigefp.treasury;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@SpringBootApplication
@EnableJpaRepositories(basePackages = "br.gov.sigefp")
@EntityScan(basePackages = "br.gov.sigefp")
@ComponentScan(basePackages = "br.gov.sigefp")
public class TestTreasuryApplication {
public static void main(String[] args) {
SpringApplication.run(TestTreasuryApplication.class, args);
}
}
@@ -0,0 +1,140 @@
package br.gov.sigefp.treasury.service;
import br.gov.sigefp.common.exception.BusinessException;
import br.gov.sigefp.common.exception.ResourceNotFoundException;
import br.gov.sigefp.treasury.api.dto.CashAccountDTO;
import br.gov.sigefp.treasury.api.dto.CreateCashAccountDTO;
import br.gov.sigefp.treasury.domain.CashAccount;
import br.gov.sigefp.treasury.repository.CashAccountRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class CashAccountServiceTest {
@Mock
private CashAccountRepository cashAccountRepository;
@InjectMocks
private CashAccountService cashAccountService;
private CashAccount cashAccount;
private CreateCashAccountDTO createDTO;
@BeforeEach
void setUp() {
cashAccount = CashAccount.builder()
.id(UUID.randomUUID())
.code("CAIXA-001")
.name("Caixa Principal")
.type("CASH")
.currentBalance(new BigDecimal("1000.00"))
.availableBalance(new BigDecimal("1000.00"))
.isActive(true)
.build();
createDTO = new CreateCashAccountDTO();
createDTO.setCode("CAIXA-001");
createDTO.setName("Caixa Principal");
createDTO.setType("CASH");
}
@Test
@DisplayName("Should create cash account successfully")
void create_Success() {
when(cashAccountRepository.findByCode(createDTO.getCode())).thenReturn(Optional.empty());
when(cashAccountRepository.save(any(CashAccount.class))).thenReturn(cashAccount);
CashAccountDTO result = cashAccountService.create(createDTO);
assertNotNull(result);
assertEquals(cashAccount.getCode(), result.getCode());
verify(cashAccountRepository).save(any(CashAccount.class));
}
@Test
@DisplayName("Should throw exception when creating duplicate code")
void create_DuplicateCode() {
when(cashAccountRepository.findByCode(createDTO.getCode())).thenReturn(Optional.of(cashAccount));
assertThrows(BusinessException.class, () -> cashAccountService.create(createDTO));
verify(cashAccountRepository, never()).save(any(CashAccount.class));
}
@Test
@DisplayName("Should update balance correctly on INFLOW")
void updateBalance_Inflow() {
when(cashAccountRepository.findById(cashAccount.getId())).thenReturn(Optional.of(cashAccount));
when(cashAccountRepository.save(any(CashAccount.class))).thenReturn(cashAccount);
BigDecimal amount = new BigDecimal("500.00");
cashAccountService.updateBalance(cashAccount.getId(), amount, "INFLOW");
assertEquals(new BigDecimal("1500.00"), cashAccount.getCurrentBalance());
assertEquals(new BigDecimal("1500.00"), cashAccount.getAvailableBalance());
}
@Test
@DisplayName("Should update balance correctly on OUTFLOW")
void updateBalance_Outflow() {
when(cashAccountRepository.findById(cashAccount.getId())).thenReturn(Optional.of(cashAccount));
when(cashAccountRepository.save(any(CashAccount.class))).thenReturn(cashAccount);
BigDecimal amount = new BigDecimal("200.00");
cashAccountService.updateBalance(cashAccount.getId(), amount, "OUTFLOW");
assertEquals(new BigDecimal("800.00"), cashAccount.getCurrentBalance());
assertEquals(new BigDecimal("800.00"), cashAccount.getAvailableBalance());
}
@Test
@DisplayName("Should commit balance successfully")
void commitBalance_Success() {
when(cashAccountRepository.findById(cashAccount.getId())).thenReturn(Optional.of(cashAccount));
when(cashAccountRepository.save(any(CashAccount.class))).thenReturn(cashAccount);
BigDecimal amount = new BigDecimal("300.00");
cashAccountService.commitBalance(cashAccount.getId(), amount);
assertEquals(new BigDecimal("1000.00"), cashAccount.getCurrentBalance()); // Current unchangd
assertEquals(new BigDecimal("700.00"), cashAccount.getAvailableBalance()); // Available reduced
}
@Test
@DisplayName("Should throw exception when committing more than available")
void commitBalance_InsufficientFunds() {
when(cashAccountRepository.findById(cashAccount.getId())).thenReturn(Optional.of(cashAccount));
BigDecimal amount = new BigDecimal("1500.00"); // More than 1000
assertThrows(BusinessException.class, () -> cashAccountService.commitBalance(cashAccount.getId(), amount));
assertEquals(new BigDecimal("1000.00"), cashAccount.getAvailableBalance()); // Should not have changed
}
@Test
@DisplayName("Should release balance correctly")
void releaseBalance_Success() {
when(cashAccountRepository.findById(cashAccount.getId())).thenReturn(Optional.of(cashAccount));
when(cashAccountRepository.save(any(CashAccount.class))).thenReturn(cashAccount);
// Assume some blocked balance, available is 1000
BigDecimal amount = new BigDecimal("200.00");
cashAccountService.releaseBalance(cashAccount.getId(), amount);
assertEquals(new BigDecimal("1200.00"), cashAccount.getAvailableBalance());
}
}
@@ -0,0 +1,139 @@
package br.gov.sigefp.treasury.service;
import br.gov.sigefp.common.exception.BusinessException;
import br.gov.sigefp.treasury.api.dto.CreatePaymentAuthorizationDTO;
import br.gov.sigefp.treasury.api.dto.PaymentAuthorizationDTO;
import br.gov.sigefp.treasury.domain.Approval;
import br.gov.sigefp.treasury.domain.PaymentAuthorization;
import br.gov.sigefp.treasury.repository.ApprovalRepository;
import br.gov.sigefp.treasury.repository.PaymentAuthorizationRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class PaymentAuthorizationServiceTest {
@Mock
private PaymentAuthorizationRepository authorizationRepository;
@Mock
private ApprovalRepository approvalRepository;
@InjectMocks
private PaymentAuthorizationService authorizationService;
private PaymentAuthorization authorization;
private CreatePaymentAuthorizationDTO createDTO;
@BeforeEach
void setUp() {
authorization = PaymentAuthorization.builder()
.id(UUID.randomUUID())
.paymentOrderId(UUID.randomUUID())
.requestedBy(UUID.randomUUID())
.requestedAt(LocalDateTime.now())
.requiredApprovalLevel(2)
.currentApprovalLevel(1)
.status("PENDING")
.approvals(new ArrayList<>())
.build();
createDTO = new CreatePaymentAuthorizationDTO();
createDTO.setPaymentOrderId(authorization.getPaymentOrderId());
createDTO.setRequestedBy(authorization.getRequestedBy());
createDTO.setRequiredApprovalLevel(2);
}
@Test
@DisplayName("Should request authorization successfully")
void requestAuthorization_Success() {
when(authorizationRepository.save(any(PaymentAuthorization.class))).thenReturn(authorization);
PaymentAuthorizationDTO result = authorizationService.requestAuthorization(createDTO);
assertNotNull(result);
assertEquals("PENDING", result.getStatus());
assertEquals(1, result.getCurrentApprovalLevel());
verify(authorizationRepository).save(any(PaymentAuthorization.class));
}
@Test
@DisplayName("Should approve authorization successfully (Partial Approval)")
void approve_PartialSuccess() {
// Setup: Level 1 of 2
when(authorizationRepository.findById(authorization.getId())).thenReturn(Optional.of(authorization));
when(authorizationRepository.save(any(PaymentAuthorization.class))).thenReturn(authorization);
// Approval repository save is called, mock it? Not strictly needed for
// void/save return unless captured
PaymentAuthorizationDTO result = authorizationService.approve(authorization.getId(), UUID.randomUUID(),
"Looks good");
// Verify status changed to PARTIALLY_APPROVED and level incremented
// Note: The service modifies the object in place, so our mock return reflects
// that if we return the SAME object
assertEquals("PARTIALLY_APPROVED", result.getStatus());
assertEquals(2, result.getCurrentApprovalLevel());
verify(approvalRepository).save(any(Approval.class));
}
@Test
@DisplayName("Should finalize approval when all levels are met")
void approve_FinalSuccess() {
// Setup: Level 2 of 2 (Already at level 2 manually for this test case?)
// Or simpler: Current Level is 1, Required is 1.
authorization.setRequiredApprovalLevel(1);
when(authorizationRepository.findById(authorization.getId())).thenReturn(Optional.of(authorization));
when(authorizationRepository.save(any(PaymentAuthorization.class))).thenReturn(authorization);
PaymentAuthorizationDTO result = authorizationService.approve(authorization.getId(), UUID.randomUUID(),
"Final OK");
assertEquals("APPROVED", result.getStatus());
verify(approvalRepository).save(any(Approval.class));
}
@Test
@DisplayName("Should reject authorization successfully")
void reject_Success() {
when(authorizationRepository.findById(authorization.getId())).thenReturn(Optional.of(authorization));
when(authorizationRepository.save(any(PaymentAuthorization.class))).thenReturn(authorization);
PaymentAuthorizationDTO result = authorizationService.reject(authorization.getId(), UUID.randomUUID(),
"Bad Payment");
assertEquals("REJECTED", result.getStatus());
assertEquals("Bad Payment", result.getRejectionReason());
}
@Test
@DisplayName("Should calculate required level correctly")
void calculateRequiredLevel_Logic() {
// <= 100,000 -> 1
assertEquals(1, authorizationService.calculateRequiredLevel(new BigDecimal("50000")));
assertEquals(1, authorizationService.calculateRequiredLevel(new BigDecimal("100000")));
// 100,001 - 500,000 -> 2
assertEquals(2, authorizationService.calculateRequiredLevel(new BigDecimal("100001")));
assertEquals(2, authorizationService.calculateRequiredLevel(new BigDecimal("500000")));
// > 500,000 -> 3
assertEquals(3, authorizationService.calculateRequiredLevel(new BigDecimal("500001")));
}
}
@@ -0,0 +1,127 @@
package br.gov.sigefp.treasury.service;
import br.gov.sigefp.rh.domain.AgentBankAccount;
import br.gov.sigefp.rh.domain.PayrollItem;
import br.gov.sigefp.rh.domain.PayrollPeriod;
import br.gov.sigefp.rh.domain.PayrollRun;
import br.gov.sigefp.rh.repository.AgentBankAccountRepository;
import br.gov.sigefp.rh.repository.PayrollItemRepository;
import br.gov.sigefp.rh.repository.PayrollRunRepository;
import br.gov.sigefp.treasury.api.dto.PaymentOrderDTO;
import br.gov.sigefp.treasury.domain.PaymentBatch;
import br.gov.sigefp.treasury.domain.PaymentOrder;
import br.gov.sigefp.treasury.repository.PaymentBatchRepository;
import br.gov.sigefp.treasury.repository.PaymentOrderRepository;
import br.gov.sigefp.common.exception.BusinessException;
import br.gov.sigefp.common.exception.ResourceNotFoundException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class PaymentOrderServiceTest {
@Mock
private PaymentOrderRepository paymentOrderRepository;
@Mock
private PaymentBatchRepository paymentBatchRepository;
@Mock
private PayrollRunRepository payrollRunRepository;
@Mock
private PayrollItemRepository payrollItemRepository;
@Mock
private AgentBankAccountRepository agentBankAccountRepository;
@InjectMocks
private PaymentOrderService paymentOrderService;
private UUID payrollRunId;
private UUID batchId;
private UUID agentId;
private PayrollRun payrollRun;
private PaymentBatch batch;
@BeforeEach
void setUp() {
payrollRunId = UUID.randomUUID();
batchId = UUID.randomUUID();
agentId = UUID.randomUUID();
PayrollPeriod period = PayrollPeriod.builder().fiscalYear(2024).month(1).build();
payrollRun = PayrollRun.builder().status("COMPLETED").period(period).build();
payrollRun.setId(payrollRunId);
batch = PaymentBatch.builder().build();
batch.setId(batchId);
}
@Test
@DisplayName("Deve gerar ordens de pagamento agrupadas por agente corretamente")
void generateOrdersFromPayrollRun_Success() {
// Mocks
when(payrollRunRepository.findById(payrollRunId)).thenReturn(Optional.of(payrollRun));
when(paymentBatchRepository.findById(batchId)).thenReturn(Optional.of(batch));
// Itens de Folha para o mesmo Agente: 1 Earning (1000) e 1 Deduction (200)
PayrollItem earning = PayrollItem.builder()
.agent(agentId)
.lineType("EARNING")
.totalAmount(new BigDecimal("1000"))
.build();
PayrollItem deduction = PayrollItem.builder()
.agent(agentId)
.lineType("DEDUCTION")
.totalAmount(new BigDecimal("200"))
.build();
when(payrollItemRepository.findByPayrollRunId(payrollRunId)).thenReturn(List.of(earning, deduction));
AgentBankAccount account = AgentBankAccount.builder().build();
account.setId(UUID.randomUUID());
when(agentBankAccountRepository.findByAgentIdAndIsPrimaryTrue(agentId)).thenReturn(Optional.of(account));
when(paymentOrderRepository.save(any(PaymentOrder.class))).thenAnswer(i -> {
PaymentOrder po = i.getArgument(0);
po.setId(UUID.randomUUID());
return po;
});
// Execute
List<PaymentOrderDTO> result = paymentOrderService.generateOrdersInternal(payrollRunId, batchId);
// Verify
assertEquals(1, result.size());
PaymentOrderDTO order = result.get(0);
assertEquals(new BigDecimal("1000"), order.getGrossAmount());
assertEquals(new BigDecimal("800"), order.getNetAmount());
assertEquals(agentId, order.getAgentId());
assertEquals(payrollRunId, order.getPayrollRunId()); // Agora é UUID nativo!
verify(paymentOrderRepository, times(1)).save(any(PaymentOrder.class));
}
@Test
@DisplayName("Deve falhar se a folha não estiver completa")
void generateOrders_FailsIfStatusNotCompleted() {
payrollRun.setStatus("PENDING");
when(payrollRunRepository.findById(payrollRunId)).thenReturn(Optional.of(payrollRun));
BusinessException exception = assertThrows(BusinessException.class,
() -> paymentOrderService.generateOrdersInternal(payrollRunId, batchId));
assertTrue(exception.getMessage().contains("Apenas execuções de folha com status COMPLETED"));
}
}
@@ -0,0 +1,151 @@
package br.gov.sigefp.treasury.service;
import br.gov.sigefp.treasury.api.dto.*;
import br.gov.sigefp.treasury.domain.CashAccount;
import br.gov.sigefp.treasury.domain.TreasuryEntryType;
import br.gov.sigefp.treasury.repository.CashAccountRepository;
import br.gov.sigefp.treasury.repository.PaymentBatchRepository;
import br.gov.sigefp.treasury.repository.PaymentOrderRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@Transactional
class TreasuryIntegrationTest {
@Autowired
private CashAccountService cashAccountService;
@Autowired
private PaymentOrderService paymentOrderService;
@Autowired
private PaymentAuthorizationService authorizationService;
@Autowired
private TreasuryEntryService treasuryEntryService;
@Autowired
private CashAccountRepository cashAccountRepository;
@Autowired
private PaymentBatchRepository paymentBatchRepository;
@Autowired
private PaymentOrderRepository paymentOrderRepository;
private UUID cashAccountId;
private UUID paymentBatchId;
private UUID paymentOrderId;
private UUID requesterId = UUID.randomUUID();
private UUID approverId = UUID.randomUUID();
@BeforeEach
void setUp() {
// 1. Create Cash Account with funds
CreateCashAccountDTO accountDTO = new CreateCashAccountDTO();
accountDTO.setCode("TEST-TSA-" + UUID.randomUUID());
accountDTO.setName("Conta Única de Teste");
accountDTO.setType("BANK_ACCOUNT");
accountDTO.setBankId(UUID.randomUUID());
CashAccountDTO account = cashAccountService.create(accountDTO);
cashAccountId = account.getId();
// Deposit funds (1,000,000 XOF)
treasuryEntryService.create(CreateTreasuryEntryDTO.builder()
.cashAccountId(cashAccountId)
.type(TreasuryEntryType.CASH_DEPOSIT)
.amount(new BigDecimal("1000000"))
.transactionDate(LocalDate.now())
.description("Dotação Inicial de Caixa")
.status("EXECUTED")
.build());
// 2. Create Payment Batch (Mocking simple Batch creation as Service not fully
// mocked here)
// Ideally use PaymentBatchService, but for speed injecting repo is fine
// regarding this integration test scope
// Actually lets use a "virtual" batch ID for order creation if allowed or
// create raw entity
// To avoid dependency hell, I'll simulate order creation directly if possible
// or create a mock batch
// Using PaymentOrderService.create requires a real batch in repo usually
br.gov.sigefp.treasury.domain.PaymentBatch batch = br.gov.sigefp.treasury.domain.PaymentBatch.builder()
.status("OPEN")
.build();
paymentBatchRepository.save(batch);
paymentBatchId = batch.getId();
// 3. Create Payment Order (150,000 XOF - Requires Level 2 Approval)
CreatePaymentOrderDTO orderDTO = CreatePaymentOrderDTO.builder()
.paymentBatchId(paymentBatchId)
.agentId(UUID.randomUUID())
.bankAccountId(UUID.randomUUID()) // Dummy bank account
.grossAmount(new BigDecimal("150000"))
.netAmount(new BigDecimal("150000"))
.build();
PaymentOrderDTO order = paymentOrderService.create(orderDTO);
paymentOrderId = order.getId();
}
@Test
@DisplayName("End-to-End: Request -> Approve -> Execute Payment")
void testEndToEndPaymentFlow() {
// Step 1: Request Authorization
// 150k requires Level 1 (<=100k) or Level 2?
// Logic: <= 100k -> 1; <= 500k -> 2. So 150k is Level 2.
Integer requiredLevel = authorizationService.calculateRequiredLevel(new BigDecimal("150000"));
assertEquals(2, requiredLevel, "Should require 2 levels of approval");
CreatePaymentAuthorizationDTO authReq = CreatePaymentAuthorizationDTO.builder()
.paymentOrderId(paymentOrderId)
.paymentBatchId(paymentBatchId)
.requestedBy(requesterId)
.requiredApprovalLevel(requiredLevel)
.build();
PaymentAuthorizationDTO auth = authorizationService.requestAuthorization(authReq);
assertEquals("PENDING", auth.getStatus());
assertEquals(1, auth.getCurrentApprovalLevel());
// Step 2: Approve Level 1
auth = authorizationService.approve(auth.getId(), approverId, "Approving Level 1");
assertEquals("PARTIALLY_APPROVED", auth.getStatus());
assertEquals(2, auth.getCurrentApprovalLevel());
// Step 3: Approve Level 2
auth = authorizationService.approve(auth.getId(), UUID.randomUUID(), "Approving Level 2 (Final)");
assertEquals("APPROVED", auth.getStatus());
// Step 4: Execute Payment (Treasury Entry Outflow)
CreateTreasuryEntryDTO paymentEntry = CreateTreasuryEntryDTO.builder()
.cashAccountId(cashAccountId)
.paymentOrderId(paymentOrderId)
.type(TreasuryEntryType.PAYMENT_EXECUTION)
.amount(new BigDecimal("150000"))
.transactionDate(LocalDate.now())
.description("Pagamento Salário Teste")
.status("EXECUTED")
.build();
TreasuryEntryDTO executedEntry = treasuryEntryService.create(paymentEntry);
assertEquals("EXECUTED", executedEntry.getStatus());
// Step 5: Verify Balance Impact
// Initial: 1,000,000 -> Paid: 150,000 -> Remaining: 850,000
BigDecimal finalBalance = cashAccountService.getCurrentBalance(cashAccountId);
assertEquals(0, finalBalance.compareTo(new BigDecimal("850000")), "Balance should be 850,000");
}
}
@@ -0,0 +1,8 @@
spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=PostgreSQL
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=false
spring.jpa.properties.hibernate.format_sql=true