🚀 Controle de Gastos com Spring Boot e HTMX

v3.0

Trilha de Aprendizado — Projeto 3 de 3

Pre-requisito: Ter concluido o Projeto 2 com o deploy funcionando no Render + Neon.

Bem-vindo ao guia da Versao 3. Neste projeto, voce vai trabalhar como em um time de desenvolvimento real: usando Metodologia Agil para evoluir a aplicacao, adicionar novas features e acompanhar a esteira de CI/CD.

O que ha de novo em relacao ao Projeto 2:

Recurso v2 v3
CSS Inline no HTML Arquivo externo (static/css/style.css)
Saldo Nao havia Card com saldo calculado em tempo real
Tamanho da lista Ilimitado Paginacao (5 itens/pagina)
Validacao de dados Nenhuma Bean Validation (@Valid, @NotBlank, @Positive)
Botao Cancelar GET /lancamentos (endpoint dedicado) GET / com deteccao do header HX-Request

Tecnologias:


🏔️ O Desafio Ágil (Product Backlog)

Para esta versão, o nosso Product Owner priorizou as seguintes Histórias de Usuário:

[US01] Cálculo de Saldo em Tempo Real

[US02] Refatoração: Separação de CSS

[US03] Automatização da Esteira (CI/CD)

[US04] API REST com Swagger


📋 Guia de Implementação (As Tarefas da Sprint)

Aqui está o passo a passo técnico para implementar as histórias acima.

Fase 1: Ajuste de Versão (Garantindo Java 17)

Como nosso ambiente de produção está rodando com Java 17, garanta que seu pom.xml esteja configurado corretamente:

<properties>
    <java.version>17</java.version>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

Fase 2: Lógica de Saldo (Atendendo a US01)

No seu LancamentoController.java, refatore o método de carregar dados para calcular o saldo e passá-lo para a View.

    // Método auxiliar para carregar dados e calcular o saldo
    private void carregarDados(Model model) {
        List<Lancamento> lancamentos = lancamentoRepository.findAll();
        lancamentos.sort(Comparator.comparing(Lancamento::getData).reversed());
        
        // Cálculo do Saldo (Receitas - Despesas)
        BigDecimal saldo = lancamentos.stream()
                .map(l -> l.getTipo() == TipoLancamento.RECEITA ? l.getValor() : l.getValor().negate())
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        
        model.addAttribute("lancamentos", lancamentos);
        model.addAttribute("saldo", saldo);
    }

Fase 3: Refatoração do CSS (Atendendo a US02)

Crie o arquivo src/main/resources/static/css/style.css e mova todos os estilos para lá. No seu index.html, remova a tag <style> e linke o arquivo externo:

<link rel="stylesheet" th:href="@{/css/style.css}">

Fase 4: Atualização da View com Saldo

Insira o card de saldo dentro do fragmento que o HTMX atualiza no index.html:

<div th:fragment="lista-lancamentos">
    <!-- Card de Saldo -->
    <div class="saldo-container">
        <div class="saldo-card" th:classappend="${saldo >= 0 ? 'positivo' : 'negativo'}">
            <h3>Saldo Atual</h3>
            <p th:text="|R$ ${#numbers.formatDecimal(saldo, 1, 'POINT', 2, 'COMMA')}|"></p>
        </div>
    </div>
    ...

Fase 5: Containerização e CI/CD (Atendendo a US03)

Para que a esteira funcione corretamente no Render e use a versão correta do Java, atualize o seu Dockerfile para usar a imagem do Java 17.

Arquivo: Dockerfile

# Estágio 1: Build da Aplicação
FROM eclipse-temurin:17-jdk-jammy as builder
WORKDIR /app
COPY .mvn/ .mvn
COPY mvnw .
COPY pom.xml .

RUN chmod +x mvnw

RUN ./mvnw dependency:go-offline
COPY src ./src
RUN ./mvnw clean package -DskipTests

# Estágio 2: Imagem Final (Alterado para Java 17)
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

🔄 Acompanhando a Esteira

Após terminar as implementações:

  1. Faça o commit e o push: git push origin main.
  2. Abra o painel do Render e acompanhe os logs do build Docker. Você verá a esteira trabalhando e o projeto subindo sozinho!

⚠️ Configuração inicial do Render (se ainda não foi feita)

Se é a primeira vez que você faz o deploy deste projeto, configure as variáveis de ambiente no serviço do Render:

Variável Valor
SPRING_PROFILES_ACTIVE prod
DB_URL jdbc:postgresql://ep-xxx.neon.tech/neondb?sslmode=require
DB_USERNAME neondb_owner
DB_PASSWORD sua senha do Neon

Atenção com a DB_URL: o Neon exibe a Connection String no formato postgresql://usuario:senha@host/db. Para o ecossistema Java/Spring, você deve:

  1. Adicionar o prefixo jdbc:jdbc:postgresql://...
  2. Remover usuario:senha@ da URL (esses valores devem ser passados separadamente em DB_USERNAME e DB_PASSWORD)
  3. Remover &channel_binding=require se estiver presente na URL

Para criar o serviço no Render e o banco no Neon do zero, consulte a Fase de criação do Banco de Dados Neon (ou a Fase 7 do Projeto 1).


💡 Recomendações Importantes para Render & Neon:

  1. Connection Pooling: O Neon possui suporte nativo a pooling de conexões (via PgBouncer). Em projetos de produção ou com alta concorrência, utilize a porta do pooler (geralmente 6543) em vez da porta padrão (5432) para evitar exaustão de conexões.
  2. Auto-suspend (Cold Start) do Neon: Na modalidade gratuita, o Neon suspende a instância de computação após 5 minutos de inatividade. O primeiro acesso após esse período pode demorar alguns segundos a mais (tempo de inicialização). Certifique-se de configurar um timeout de conexão adequado no Spring Boot (ex: connectionTimeout = 30000ms via HikariCP) para evitar falhas durante o aquecimento.
  3. Spin-down do Render (Free Tier): Os serviços web gratuitos do Render entram em modo de suspensão após 15 minutos de inatividade. O primeiro request pode demorar até 50 segundos para subir o contêiner. Para evitar isso em demonstrações ou ambientes de teste críticos, configure um pinger simples (como o UptimeRobot) para fazer requisições periódicas a cada 10-14 minutos.
  4. Tratamento de SSL/TLS: Certifique-se de manter o parâmetro ?sslmode=require na URL de conexão para garantir que todo o tráfego entre a aplicação no Render e o banco de dados Neon seja criptografado de forma segura.

✅ Checkpoint — Após o deploy

  1. Aguarde a finalização do build no painel do Render (pode levar de 5 a 10 minutos).
  2. Acesse a URL pública fornecida pelo Render e verifique se a aplicação carrega corretamente.
  3. Adicione um novo lançamento e verifique se ele é persistido após atualizar (refresh) a página.

Fase 6: Validação de Dados (Bean Validation)

Para evitar que dados inválidos ou vazios sejam salvos no banco, vamos usar o Bean Validation.

6.1 Adicionar Dependência

No arquivo pom.xml, adicione a dependência de validação:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

6.2 Anotar o Model

Abra Lancamento.java e adicione as anotações de validação nos campos:

@NotBlank(message = "A descrição é obrigatória")
private String descricao;

@NotNull(message = "O valor é obrigatório")
@Positive(message = "O valor deve ser positivo")
private BigDecimal valor;

6.3 Validar no Controller

No LancamentoController.java, use @Valid no método addLancamento e trate os erros:

Nota: Neste momento carregarDados ainda tem a assinatura sem o parametro de pagina (isso muda na Fase 7). Use carregarDados(model) aqui — na Fase 7 voce vai atualizar a assinatura e substituir por carregarDados(model, 0).

@PostMapping("/lancamentos")
public String addLancamento(@Valid @ModelAttribute("novoLancamento") Lancamento novoLancamento, BindingResult result, Model model) {
    if (result.hasErrors()) {
        carregarDados(model);
        model.addAttribute("tipos", TipoLancamento.values());
        model.addAttribute("lancamentoParaEditar", new Lancamento());
        return "index"; // Retorna a pagina inteira com os erros
    }
    lancamentoRepository.save(novoLancamento);
    carregarDados(model);
    return "index :: lista-lancamentos";
}

6.4 Exibir Erros na View

No index.html, atualize o formulário para exibir as mensagens de erro:

<div style="flex-grow: 2; display: flex; flex-direction: column;">
    <input type="text" th:field="*{descricao}" placeholder="Descrição" required>
    <span th:if="${#fields.hasErrors('descricao')}" th:errors="*{descricao}" style="color: var(--red-color); font-size: 12px;"></span>
</div>

Fase 7: Implementando Paginação

Para evitar que a lista fique infinitamente grande, vamos limitar a exibição a 5 itens por página.

7.1 Atualizar o Controller

No LancamentoController.java, altere o método carregarDados para aceitar o número da página:

private void carregarDados(Model model, int page) {
    Pageable pageable = PageRequest.of(page, 5, Sort.by("data").descending());
    Page<Lancamento> lancamentosPage = lancamentoRepository.findAll(pageable);
    
    // Cálculo do saldo (usando todos os lançamentos para o saldo total)
    List<Lancamento> todosLancamentos = lancamentoRepository.findAll();
    BigDecimal saldo = todosLancamentos.stream()
            .map(l -> l.getTipo() == TipoLancamento.RECEITA ? l.getValor() : l.getValor().negate())
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    
    model.addAttribute("lancamentos", lancamentosPage.getContent());
    model.addAttribute("saldo", saldo);
    model.addAttribute("currentPage", page);
    model.addAttribute("totalPages", lancamentosPage.getTotalPages());
}

7.2 Evitar Nesting no HTMX

Como a paginação faz uma requisição para / buscando a próxima página, se retornarmos o template index inteiro, o HTMX vai colocar a página inteira dentro da tabela (gerando um efeito de “página dentro de página”). Para evitar isso, altere o método index para retornar apenas o fragmento quando a requisição for do HTMX:

@GetMapping("/")
public String index(@RequestParam(defaultValue = "0") int page, 
                    @RequestHeader(value = "HX-Request", required = false) boolean isHtmx, 
                    Model model) {
    carregarDados(model, page);
    model.addAttribute("novoLancamento", new Lancamento());
    model.addAttribute("tipos", TipoLancamento.values());
    model.addAttribute("lancamentoParaEditar", new Lancamento());
    
    if (isHtmx) {
        return "index :: lista-lancamentos";
    }
    return "index";
}

7.3 Adicionar Controles na View

No index.html, adicione o bloco de paginação no final do fragmento lista-lancamentos:

<div class="pagination" th:if="${totalPages > 1}" style="display: flex; justify-content: center; gap: 8px; margin-top: 20px;">
    <button th:each="pageNumber : ${#numbers.sequence(0, totalPages - 1)}"
            th:text="${pageNumber + 1}"
            th:classappend="${pageNumber == currentPage} ? 'active' : ''"
            th:hx-get="@{/(page=${pageNumber})}"
            hx-target="#lista-lancamentos"
            hx-swap="innerHTML">
    </button>
</div>

Fase 8: API REST com Swagger (Atendendo a US04)

Nesta fase, a aplicacao passa a expor dois tipos de interface ao mesmo tempo:

graph LR
    subgraph Browser
        U["Usuário\nInterface Web"]
        S["Swagger UI\n/swagger-ui/index.html"]
    end
    subgraph "Spring Boot"
        TC["LancamentoController\nGET / POST / PUT / DELETE\nRetorna HTML (Thymeleaf)"]
        RC["LancamentoRestController\n/api/v1/lancamentos\nRetorna JSON"]
    end
    subgraph "Banco"
        DB[("PostgreSQL\nLancamentoRepository")]
    end

    U -->|HTMX requests| TC
    S -->|HTTP requests| RC
    TC --> DB
    RC --> DB

8.1 Adicionar Dependencia no pom.xml

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.6.0</version>
</dependency>

springdoc-openapi le as anotacoes @RestController, @Operation e @Tag e gera a documentacao automaticamente em /swagger-ui/index.html.

8.2 Criar LancamentoRestController.java

package br.com.controledegastos.controller;

import br.com.controledegastos.model.Lancamento;
import br.com.controledegastos.repository.LancamentoRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.net.URI;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;

@RestController
@RequestMapping("/api/v1/lancamentos")
@Tag(name = "Lancamentos", description = "CRUD de lancamentos financeiros")
public class LancamentoRestController {

    @Autowired
    private LancamentoRepository lancamentoRepository;

    @GetMapping
    @Operation(summary = "Listar todos os lancamentos")
    public ResponseEntity<List<Lancamento>> listar() {
        List<Lancamento> lancamentos = lancamentoRepository.findAll();
        lancamentos.sort(Comparator.comparing(Lancamento::getData).reversed());
        return ResponseEntity.ok(lancamentos);
    }

    @GetMapping("/{id}")
    @Operation(summary = "Buscar lancamento por ID")
    @ApiResponses({
        @ApiResponse(responseCode = "200", description = "Lancamento encontrado"),
        @ApiResponse(responseCode = "404", description = "Nao encontrado")
    })
    public ResponseEntity<Lancamento> buscarPorId(
            @Parameter(description = "ID do lancamento") @PathVariable Long id) {
        return lancamentoRepository.findById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    @Operation(summary = "Criar novo lancamento")
    @ApiResponse(responseCode = "201", description = "Criado com sucesso")
    public ResponseEntity<Lancamento> criar(@Valid @RequestBody Lancamento lancamento) {
        Lancamento salvo = lancamentoRepository.save(lancamento);
        return ResponseEntity.created(URI.create("/api/v1/lancamentos/" + salvo.getId())).body(salvo);
    }

    @PutMapping("/{id}")
    @Operation(summary = "Atualizar lancamento existente")
    public ResponseEntity<Lancamento> atualizar(
            @PathVariable Long id, @Valid @RequestBody Lancamento lancamentoAtualizado) {
        Optional<Lancamento> lancamentoOpt = lancamentoRepository.findById(id);
        if (lancamentoOpt.isPresent()) {
            Lancamento existente = lancamentoOpt.get();
            existente.setDescricao(lancamentoAtualizado.getDescricao());
            existente.setValor(lancamentoAtualizado.getValor());
            existente.setTipo(lancamentoAtualizado.getTipo());
            existente.setData(lancamentoAtualizado.getData());
            return ResponseEntity.ok(lancamentoRepository.save(existente));
        }
        return ResponseEntity.notFound().build();
    }

    @DeleteMapping("/{id}")
    @Operation(summary = "Excluir lancamento")
    @ApiResponse(responseCode = "204", description = "Excluido com sucesso")
    public ResponseEntity<Void> excluir(@PathVariable Long id) {
        if (lancamentoRepository.existsById(id)) {
            lancamentoRepository.deleteById(id);
            return ResponseEntity.noContent().build();
        }
        return ResponseEntity.notFound().build();
    }
}

Diferencas entre @Controller e @RestController:

@Controller (Thymeleaf) @RestController (REST API)
Retorna nome de template ("index") Retorna objeto Java serializado como JSON
Resposta: text/html Resposta: application/json
Usa Model para passar dados Usa ResponseEntity<T>
Para interfaces web Para integrações e APIs

8.3 Criar SwaggerConfig.java

package br.com.controledegastos.config;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SwaggerConfig {

    @Bean
    public OpenAPI openAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title("Controle de Gastos API")
                        .version("3.0.0")
                        .description("API REST para gerenciamento de lancamentos. Interface web disponivel em /"));
    }
}

No index.html, atualize o footer para incluir o link:

<footer style="text-align: center; margin-top: 20px; color: var(--subtext-color); font-size: 14px;">
    <p>&copy; 2026 - Controle de Gastos v3</p>
    <p>
        <a th:href="@{/swagger-ui/index.html}" target="_blank"
           style="color: var(--primary-color); text-decoration: none; font-size: 12px;">
            📖 API REST — Swagger UI
        </a>
    </p>
</footer>

8.5 Testar o Swagger

Suba a aplicacao e acesse: http://localhost:8080/swagger-ui/index.html

Voce vera todos os endpoints documentados e pode executar chamadas diretamente pelo browser (sem Postman ou curl).

Conceito REST — Verbos HTTP e Recursos:

Verbo Endpoint Acao HTTP Status
GET /api/v1/lancamentos Listar todos 200 OK
GET /api/v1/lancamentos/{id} Buscar por ID 200 / 404
POST /api/v1/lancamentos Criar novo 201 Created
PUT /api/v1/lancamentos/{id} Atualizar 200 OK
DELETE /api/v1/lancamentos/{id} Excluir 204 No Content

🏆 Gabarito Completo (Código Final)

Aqui estão os códigos completos de todos os arquivos do projeto. Use para conferir ou corrigir erros!

1. pom.xml

<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.5.5</version>
        <relativePath/>
    </parent>
    <groupId>br.com.controledegastos</groupId>
    <artifactId>controle-de-gastos</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>controle-de-gastos</name>
    <description>Aplicação para controle de gastos pessoais</description>
    <properties>
        <java.version>17</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>2.6.0</version>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

2. Dockerfile

# Estagio 1: Build
FROM eclipse-temurin:17-jdk-jammy as builder
WORKDIR /app
COPY .mvn/ .mvn
COPY mvnw .
COPY pom.xml .
RUN chmod +x mvnw
RUN ./mvnw dependency:go-offline
COPY src ./src
RUN ./mvnw clean package -DskipTests

# Estagio 2: Imagem final (mais leve)
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

3. src/main/resources/application.properties

# Configuracoes do H2 Database (perfil 'default' - desenvolvimento local)
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.h2.console.enabled=true
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=update

4. src/main/resources/application-prod.properties

# Configuracoes para producao (Render + Neon PostgreSQL)
# Ative este perfil no Render com: SPRING_PROFILES_ACTIVE=prod
spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}
spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.hibernate.ddl-auto=update
spring.h2.console.enabled=false

5. TipoLancamento.java

package br.com.controledegastos.model;

public enum TipoLancamento {
    RECEITA,
    DESPESA
}

6. Lancamento.java

package br.com.controledegastos.model;

import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
import java.time.LocalDate;

@Entity
public class Lancamento {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank(message = "A descricao e obrigatoria")
    private String descricao;

    @NotNull(message = "O valor e obrigatorio")
    @Positive(message = "O valor deve ser positivo")
    private BigDecimal valor;

    private LocalDate data = LocalDate.now();

    @Enumerated(EnumType.STRING)
    private TipoLancamento tipo;

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getDescricao() { return descricao; }
    public void setDescricao(String descricao) { this.descricao = descricao; }
    public BigDecimal getValor() { return valor; }
    public void setValor(BigDecimal valor) { this.valor = valor; }
    public LocalDate getData() { return data; }
    public void setData(LocalDate data) { this.data = data; }
    public TipoLancamento getTipo() { return tipo; }
    public void setTipo(TipoLancamento tipo) { this.tipo = tipo; }
}

7. LancamentoRepository.java

package br.com.controledegastos.repository;

import br.com.controledegastos.model.Lancamento;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface LancamentoRepository extends JpaRepository<Lancamento, Long> {
}

8. ControleDeGastosApplication.java

package br.com.controledegastos;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ControleDeGastosApplication {

    public static void main(String[] args) {
        SpringApplication.run(ControleDeGastosApplication.class, args);
    }
}

9. LancamentoController.java

package br.com.controledegastos.controller;

import br.com.controledegastos.model.Lancamento;
import br.com.controledegastos.model.TipoLancamento;
import br.com.controledegastos.repository.LancamentoRepository;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
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.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;

@Controller
public class LancamentoController {

    @Autowired
    private LancamentoRepository lancamentoRepository;

    private void carregarDados(Model model, int page) {
        Pageable pageable = PageRequest.of(page, 5, Sort.by("data").descending());
        Page<Lancamento> lancamentosPage = lancamentoRepository.findAll(pageable);
        
        List<Lancamento> todosLancamentos = lancamentoRepository.findAll();
        BigDecimal saldo = todosLancamentos.stream()
                .map(l -> l.getTipo() == TipoLancamento.RECEITA ? l.getValor() : l.getValor().negate())
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        
        model.addAttribute("lancamentos", lancamentosPage.getContent());
        model.addAttribute("saldo", saldo);
        model.addAttribute("currentPage", page);
        model.addAttribute("totalPages", lancamentosPage.getTotalPages());
    }

    @GetMapping("/")
    public String index(@RequestParam(defaultValue = "0") int page, 
                        @RequestHeader(value = "HX-Request", required = false) boolean isHtmx, 
                        Model model) {
        carregarDados(model, page);
        model.addAttribute("novoLancamento", new Lancamento());
        model.addAttribute("tipos", TipoLancamento.values());
        model.addAttribute("lancamentoParaEditar", new Lancamento());
        
        if (isHtmx) {
            return "index :: lista-lancamentos";
        }
        return "index";
    }

    @PostMapping("/lancamentos")
    public String addLancamento(@Valid @ModelAttribute("novoLancamento") Lancamento novoLancamento, BindingResult result, Model model) {
        if (result.hasErrors()) {
            carregarDados(model, 0);
            model.addAttribute("tipos", TipoLancamento.values());
            model.addAttribute("lancamentoParaEditar", new Lancamento());
            return "index";
        }
        lancamentoRepository.save(novoLancamento);
        carregarDados(model, 0);
        return "index :: lista-lancamentos";
    }

    @DeleteMapping("/lancamentos/{id}")
    public String deleteLancamento(@PathVariable Long id, @RequestParam(defaultValue = "0") int page, Model model) {
        lancamentoRepository.deleteById(id);
        carregarDados(model, page);
        return "index :: lista-lancamentos";
    }

    @GetMapping("/lancamentos/editar/{id}")
    public String editLancamento(@PathVariable Long id, Model model) {
        Optional<Lancamento> lancamentoOpt = lancamentoRepository.findById(id);
        if (lancamentoOpt.isPresent()) {
            model.addAttribute("lancamentoParaEditar", lancamentoOpt.get());
            model.addAttribute("tipos", TipoLancamento.values());
            return "index :: form-edicao";
        }
        carregarDados(model, 0);
        return "index :: lista-lancamentos";
    }

    @PutMapping("/lancamentos/{id}")
    public String updateLancamento(@PathVariable Long id, @Valid @ModelAttribute("lancamentoParaEditar") Lancamento lancamentoAtualizado, BindingResult result, Model model) {
        if (result.hasErrors()) {
            carregarDados(model, 0);
            model.addAttribute("tipos", TipoLancamento.values());
            return "index";
        }
        Optional<Lancamento> lancamentoOpt = lancamentoRepository.findById(id);
        if (lancamentoOpt.isPresent()) {
            Lancamento lancamentoExistente = lancamentoOpt.get();
            lancamentoExistente.setDescricao(lancamentoAtualizado.getDescricao());
            lancamentoExistente.setValor(lancamentoAtualizado.getValor());
            lancamentoExistente.setTipo(lancamentoAtualizado.getTipo());
            lancamentoExistente.setData(lancamentoAtualizado.getData());
            lancamentoRepository.save(lancamentoExistente);
        }
        carregarDados(model, 0);
        return "index :: lista-lancamentos";
    }
}

10. LancamentoRestController.java

package br.com.controledegastos.controller;

import br.com.controledegastos.model.Lancamento;
import br.com.controledegastos.repository.LancamentoRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.net.URI;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;

@RestController
@RequestMapping("/api/v1/lancamentos")
@Tag(name = "Lancamentos", description = "CRUD de lancamentos financeiros")
public class LancamentoRestController {

    @Autowired
    private LancamentoRepository lancamentoRepository;

    @GetMapping
    @Operation(summary = "Listar todos os lancamentos", description = "Retorna a lista completa ordenada por data decrescente")
    public ResponseEntity<List<Lancamento>> listar() {
        List<Lancamento> lancamentos = lancamentoRepository.findAll();
        lancamentos.sort(Comparator.comparing(Lancamento::getData).reversed());
        return ResponseEntity.ok(lancamentos);
    }

    @GetMapping("/{id}")
    @Operation(summary = "Buscar lancamento por ID")
    @ApiResponses({
        @ApiResponse(responseCode = "200", description = "Lancamento encontrado"),
        @ApiResponse(responseCode = "404", description = "Lancamento nao encontrado")
    })
    public ResponseEntity<Lancamento> buscarPorId(
            @Parameter(description = "ID do lancamento") @PathVariable Long id) {
        return lancamentoRepository.findById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    @Operation(summary = "Criar novo lancamento")
    @ApiResponse(responseCode = "201", description = "Lancamento criado com sucesso")
    public ResponseEntity<Lancamento> criar(@Valid @RequestBody Lancamento lancamento) {
        Lancamento salvo = lancamentoRepository.save(lancamento);
        return ResponseEntity.created(URI.create("/api/v1/lancamentos/" + salvo.getId())).body(salvo);
    }

    @PutMapping("/{id}")
    @Operation(summary = "Atualizar lancamento existente")
    public ResponseEntity<Lancamento> atualizar(
            @PathVariable Long id,
            @Valid @RequestBody Lancamento lancamentoAtualizado) {
        Optional<Lancamento> lancamentoOpt = lancamentoRepository.findById(id);
        if (lancamentoOpt.isPresent()) {
            Lancamento existente = lancamentoOpt.get();
            existente.setDescricao(lancamentoAtualizado.getDescricao());
            existente.setValor(lancamentoAtualizado.getValor());
            existente.setTipo(lancamentoAtualizado.getTipo());
            existente.setData(lancamentoAtualizado.getData());
            return ResponseEntity.ok(lancamentoRepository.save(existente));
        }
        return ResponseEntity.notFound().build();
    }

    @DeleteMapping("/{id}")
    @Operation(summary = "Excluir lancamento")
    @ApiResponse(responseCode = "204", description = "Lancamento excluido com sucesso")
    public ResponseEntity<Void> excluir(@PathVariable Long id) {
        if (lancamentoRepository.existsById(id)) {
            lancamentoRepository.deleteById(id);
            return ResponseEntity.noContent().build();
        }
        return ResponseEntity.notFound().build();
    }
}

11. SwaggerConfig.java

package br.com.controledegastos.config;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SwaggerConfig {

    @Bean
    public OpenAPI openAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title("Controle de Gastos API")
                        .version("3.0.0")
                        .description("API REST para gerenciamento de lancamentos financeiros. Interface web disponivel em /"));
    }
}

12. src/main/resources/templates/index.html

<!DOCTYPE html>
<html lang="pt-br" xmlns:th="http://www.thymeleaf.org" class="light">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Controle de Gastos</title>
    <link rel="stylesheet" th:href="@{/css/style.css}">
    <script src="https://unpkg.com/htmx.org@1.9.12"></script>
</head>
<body>
    <button id="theme-toggle">🌙</button>
    <div class="container">
        <h1>Meu Controle de Gastos 2.3 💰</h1>

        <!-- Formulário de Cadastro -->
        <form th:object="${novoLancamento}" hx-post="/lancamentos"
              hx-target="#lista-lancamentos"
              hx-swap="innerHTML"
              hx-on::after-request="this.reset()">
            <div style="display: flex; gap: 12px; flex-wrap: wrap; width: 100%;">
                <div style="flex-grow: 2; display: flex; flex-direction: column;">
                    <input type="text" th:field="*{descricao}" placeholder="Descrição" required>
                    <span th:if="${#fields.hasErrors('descricao')}" th:errors="*{descricao}" style="color: var(--red-color); font-size: 12px;"></span>
                </div>
                <div style="flex-grow: 1; display: flex; flex-direction: column;">
                    <input type="number" step="0.01" th:field="*{valor}" placeholder="Valor" required>
                    <span th:if="${#fields.hasErrors('valor')}" th:errors="*{valor}" style="color: var(--red-color); font-size: 12px;"></span>
                </div>
                <div style="flex-grow: 1;">
                    <select th:field="*{tipo}" required style="width: 100%; height: 38px;">
                        <option th:each="tipoOpt : ${tipos}" th:value="${tipoOpt}" th:text="${tipoOpt}"></option>
                    </select>
                </div>
                <button type="submit" class="add-btn" style="height: 38px;">Adicionar</button>
            </div>
        </form>

        <!-- Container que o HTMX vai atualizar -->
        <div id="lista-lancamentos">
            <div th:fragment="lista-lancamentos">
                <!-- Card de Saldo -->
                <div class="saldo-container">
                    <div class="saldo-card" th:classappend="${saldo >= 0 ? 'positivo' : 'negativo'}">
                        <h3>Saldo Atual</h3>
                        <p th:text="|R$ ${#numbers.formatDecimal(saldo, 1, 'POINT', 2, 'COMMA')}|"></p>
                    </div>
                </div>

                <!-- Tabela de Lançamentos -->
                <div class="list-header">
                    <div class="col-desc">Descrição</div>
                    <div class="col-valor">Valor</div>
                    <div class="col-data">Data</div>
                    <div class="col-tipo">Tipo</div>
                    <div class="col-acoes">Ações</div>
                </div>

                <div th:each="lancamento : ${lancamentos}" th:id="'lancamento-' + ${lancamento.id}" class="list-item">
                    <div class="col-desc" th:text="${lancamento.descricao}"></div>
                    <div class="col-valor valor" th:text="|R$ ${#numbers.formatDecimal(lancamento.valor, 1, 'POINT', 2, 'COMMA')}|"
                         th:classappend="${lancamento.tipo.name() == 'DESPESA' ? 'despesa' : 'receita'}">
                    </div>
                    <div class="col-data" th:text="${#temporals.format(lancamento.data, 'dd/MM/yyyy')}"></div>
                    <div class="col-tipo" th:text="${lancamento.tipo.name()}"></div>
                    <div class="col-acoes">
                        <div class="actions-wrapper">
                            <button class="action-btn edit-btn"
                                    th:hx-get="@{/lancamentos/editar/{id}(id=${lancamento.id})}"
                                    th:hx-target="'#lancamento-' + ${lancamento.id}"
                                    hx-swap="outerHTML">
                                Alterar
                            </button>
                            <button class="action-btn delete-btn"
                                    th:hx-delete="@{/lancamentos/{id}(id=${lancamento.id}, page=${currentPage})}"
                                    hx-target="#lista-lancamentos"
                                    hx-swap="innerHTML"
                                    hx-confirm="Tem certeza que deseja excluir?">
                                Excluir
                            </button>
                        </div>
                    </div>
                </div>

                <!-- Paginação -->
                <div class="pagination" th:if="${totalPages > 1}" style="display: flex; justify-content: center; gap: 8px; margin-top: 20px;">
                    <button th:each="pageNumber : ${#numbers.sequence(0, totalPages - 1)}"
                            th:text="${pageNumber + 1}"
                            th:classappend="${pageNumber == currentPage} ? 'active' : ''"
                            th:hx-get="@{/(page=${pageNumber})}"
                            hx-target="#lista-lancamentos"
                            hx-swap="innerHTML"
                            style="padding: 5px 10px; border: 1px solid var(--border-color); border-radius: 3px; cursor: pointer; background-color: var(--card-bg-color); color: var(--text-color);">
                    </button>
                </div>
            </div>
        </div>
        
        <!-- Footer -->
        <footer style="text-align: center; margin-top: 20px; color: var(--subtext-color); font-size: 14px;">
            <p>&copy; 2026 - Controle de Gastos v3</p>
            <p>
                <a th:href="@{/swagger-ui/index.html}" target="_blank"
                   style="color: var(--primary-color); text-decoration: none; font-size: 12px;">
                    📖 API REST — Swagger UI
                </a>
            </p>
        </footer>
    </div>

    <!-- Fragmento Oculto para Edição -->
    <div style="display: none;">
        <th:block th:fragment="form-edicao">
            <div th:id="'lancamento-' + ${lancamentoParaEditar.id}" class="edit-form-row">
                <div class="col-desc">
                    <input type="text" name="descricao" th:value="${lancamentoParaEditar.descricao}">
                </div>
                <div class="col-valor">
                    <input type="number" step="0.01" name="valor" th:value="${lancamentoParaEditar.valor}">
                </div>
                <div class="col-data">
                    <input type="date" name="data" th:value="${#temporals.format(lancamentoParaEditar.data, 'yyyy-MM-dd')}">
                </div>
                <div class="col-tipo">
                    <select name="tipo">
                        <option th:each="tipoOpt : ${tipos}"
                                th:value="${tipoOpt}"
                                th:text="${tipoOpt}"
                                th:selected="${tipoOpt == lancamentoParaEditar.tipo}"></option>
                    </select>
                </div>
                <div class="col-acoes">
                    <div class="actions-wrapper">
                        <button class="action-btn save-btn"
                                th:hx-put="@{/lancamentos/{id}(id=${lancamentoParaEditar.id})}"
                                hx-target="#lista-lancamentos"
                                hx-swap="innerHTML"
                                hx-include="closest .edit-form-row">
                            Salvar
                        </button>
                        <button class="action-btn cancel-btn"
                                th:hx-get="@{/}"
                                hx-target="#lista-lancamentos"
                                hx-swap="innerHTML">
                            Cancelar
                        </button>
                    </div>
                </div>
            </div>
        </th:block>
    </div>

    <script>
        const themeToggle = document.getElementById('theme-toggle');
        const html = document.documentElement;
        themeToggle.addEventListener('click', () => {
            if (html.classList.contains('dark')) {
                html.classList.remove('dark');
                html.classList.add('light');
                themeToggle.textContent = '🌙';
            } else {
                html.classList.remove('light');
                html.classList.add('dark');
                themeToggle.textContent = '☀️';
            }
        });
    </script>
</body>
</html>

13. src/main/resources/static/css/style.css

:root {
    --bg-color: #f4f5f7;
    --card-bg-color: #ffffff;
    --text-color: #172b4d;
    --subtext-color: #6b778c;
    --border-color: #dfe1e6;
    --primary-color: #0052cc;
    --primary-hover-color: #0065ff;
    --green-color: #36b37e;
    --red-color: #de350b;
    --yellow-color: #ffab00;
    --grey-color: #6b778c;
    --shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}

html.dark {
    --bg-color: #172b4d;
    --card-bg-color: #283e5d;
    --text-color: #b0c0db;
    --subtext-color: #8c9cb5;
    --border-color: #42526e;
    --primary-color: #4c9aff;
    --primary-hover-color: #69b4ff;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    margin: 0;
    padding: 40px;
    background-color: var(--bg-color);
    color: var(--text-color);
    transition: background-color 0.3s, color 0.3s;
}

.container {
    max-width: 800px;
    margin: auto;
    background: var(--card-bg-color);
    padding: 24px;
    border-radius: 8px;
    box-shadow: var(--shadow);
}

/* Card de Saldo */
.saldo-container {
    display: flex;
    justify-content: center;
    margin-bottom: 20px;
}

.saldo-card {
    background: var(--bg-color);
    padding: 15px 30px;
    border-radius: 8px;
    text-align: center;
    border: 2px solid var(--border-color);
    min-width: 200px;
}

.saldo-card h3 {
    margin: 0;
    font-size: 14px;
    text-transform: uppercase;
    color: var(--subtext-color);
}

.saldo-card p {
    margin: 5px 0 0 0;
    font-size: 24px;
    font-weight: bold;
}

.saldo-card.positivo p { color: var(--green-color); }
.saldo-card.negativo p { color: var(--red-color); }

/* Formulários */
form {
    display: flex;
    gap: 12px;
    margin-bottom: 24px;
    flex-wrap: wrap;
}

input, select {
    padding: 10px;
    border-radius: 5px;
    border: 1px solid var(--border-color);
    font-size: 14px;
    background-color: var(--bg-color);
    color: var(--text-color);
}

button {
    padding: 10px 15px;
    border-radius: 5px;
    border: none;
    font-size: 14px;
    font-weight: 600;
    color: white;
    cursor: pointer;
    transition: background-color 0.2s;
}

.add-btn { background-color: var(--primary-color); }
.add-btn:hover { background-color: var(--primary-hover-color); }

/* Estrutura da Lista */
.list-header, .list-item, .edit-form-row {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 12px 0;
    border-bottom: 1px solid var(--border-color);
}

.list-header {
    font-weight: 600;
    color: var(--subtext-color);
    font-size: 12px;
    text-transform: uppercase;
}

.col-desc { flex: 4; }
.col-valor { flex: 2; }
.col-data { flex: 2; }
.col-tipo { flex: 2; }
.col-acoes { flex: 3; justify-content: flex-end; display: flex;}

.valor { text-align: right; }
.valor.despesa { color: var(--red-color); }
.valor.receita { color: var(--green-color); }

.actions-wrapper { display: flex; gap: 8px; }
.action-btn { font-size: 12px; padding: 6px 12px; }
.edit-btn { background-color: var(--yellow-color); }
.delete-btn { background-color: var(--red-color); }
.save-btn { background-color: var(--green-color); }
.cancel-btn { background-color: var(--grey-color); }

#theme-toggle {
    position: fixed;
    top: 20px;
    right: 20px;
    background-color: var(--card-bg-color);
    color: var(--text-color);
    border: 1px solid var(--border-color);
    width: 40px;
    height: 40px;
    border-radius: 50%;
    font-size: 20px;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
}

⚠️ Erros Comuns e Como Resolver

Erro 1 — App não sobe no Render: Typo na DB_URL

Sintoma no log:

Driver org.postgresql.Driver claims to not accept jdbcUrl,
jdcb:postgresql://...

Causa: A variável DB_URL foi digitada com jdcb: em vez de jdbc:.

Solução: Verifique no painel do Render → Environment Variables:

❌ ERRADO:  jdcb:postgresql://host/db?sslmode=require
✅ CORRETO: jdbc:postgresql://host/db?sslmode=require

O Neon fornece a Connection String no formato libpq (postgresql://...). Para Java, adicione o prefixo jdbc: manualmente → jdbc:postgresql://....


Erro 2 — App não sobe: credenciais embutidas na DB_URL

Sintoma no log:

JDBC URL invalid port number: <senha>@<host>
Driver org.postgresql.Driver claims to not accept jdbcUrl,
jdbc:postgresql://neondb_owner:<senha>@ep-xxx.neon.tech/neondb?sslmode=require

Causa: O Neon fornece a Connection String no formato libpq, com usuário e senha embutidos na URL:

postgresql://neondb_owner:<senha>@ep-xxx.neon.tech/neondb?sslmode=require

O driver JDBC do PostgreSQL não suporta o padrão user:password@host — ele tenta interpretar <senha>@<host> como número de porta e falha.

Solução: Separe as credenciais da URL nas variáveis de ambiente do Render:

Variável Valor correto
DB_URL jdbc:postgresql://ep-xxx.neon.tech/neondb?sslmode=require
DB_USERNAME neondb_owner
DB_PASSWORD <sua senha do Neon>

Dica: o Neon também oferece o parâmetro &channel_binding=require na Connection String. Remova-o da DB_URL — ele não é necessário para Java/JDBC.


Erro 3 — Warning HHH90000025 nos logs

Sintoma:

HHH90000025: PostgreSQLDialect does not need to be specified explicitly

Causa: O arquivo application-prod.properties contém a linha:

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect

Solução: Remova essa linha. O Hibernate 6 (usado pelo Spring Boot 3.x) detecta o dialeto automaticamente.


Conceito — Por que o botão “Cancelar” usa @{/} e funciona?

Em versões anteriores, chamar @{/} no botão cancelar era um bug: o HTMX recebia a página HTML inteira e a injetava dentro do <div> da lista.

Nesta versão, o problema foi resolvido de forma elegante no próprio Controller. O método index() detecta o cabeçalho HX-Request que o HTMX sempre envia automaticamente em todas as suas requisições:

@GetMapping("/")
public String index(...,
                    @RequestHeader(value = "HX-Request", required = false) boolean isHtmx,
                    ...) {
    ...
    if (isHtmx) {
        return "index :: lista-lancamentos"; // Retorna só o fragmento
    }
    return "index"; // Retorna a página inteira (acesso normal do browser)
}

Isso também resolve a paginação: os botões de página chamam GET /?page=N via HTMX — como incluem o cabeçalho HX-Request, o servidor responde com o fragmento correto.


✅ Resumo das Correções da v3

Problema Causa Solução
Permission denied no build mvnw sem flag de execução git update-index --chmod=+x mvnw + RUN chmod +x mvnw no Dockerfile
MalformedInputException Acentos em arquivos .properties Comentários sem acentos nos arquivos de configuração
Warning HHH90000025 Dialeto declarado explicitamente Remover hibernate.dialect do application-prod.properties
App não sobe: typo na DB_URL jdcb: em vez de jdbc: Corrigir a variável no painel do Render
JDBC URL invalid port number Credenciais embutidas na URL (user:pass@host) Separar em DB_URL (sem credenciais), DB_USERNAME e DB_PASSWORD
editLancamento quebra ao não encontrar ID Model vazio ao renderizar fragmento Chamar carregarDados(model, 0) antes do retorno alternativo

✅ Conclusao da Trilha

Parabens! Voce concluiu os tres projetos da serie Spring Boot + HTMX.

O que voce construiu e aprendeu em cada versao:

Versao Features Conceitos novos
v1 Criar, Listar, Excluir Spring MVC, Thymeleaf, HTMX (outerHTML), H2, Docker, Render + Neon
v2 + Editar Fragment innerHTML, HTTP PUT, CSS variables, tema claro/escuro
v3 + Paginacao, Saldo, Validacao Pageable, Bean Validation (@Valid), header HX-Request, CSS externo

Tecnologias dominadas: