diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..3b41682
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+/mvnw text eol=lf
+*.cmd text eol=crlf
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..667aaef
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,33 @@
+HELP.md
+target/
+.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000..c0bcafe
--- /dev/null
+++ b/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,3 @@
+wrapperVersion=3.3.4
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..8d5e0d9
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,15 @@
+FROM eclipse-temurin:17-jdk-alpine AS build
+WORKDIR /app
+COPY mvnw pom.xml ./
+COPY .mvn .mvn
+RUN chmod +x mvnw
+RUN ./mvnw dependency:go-offline
+
+COPY src src
+RUN ./mvnw clean package -DskipTests
+
+FROM eclipse-temurin:17-jre-alpine
+WORKDIR /app
+COPY --from=build /app/target/*SNAPSHOT.jar app.jar
+EXPOSE 8080
+ENTRYPOINT ["java","-jar","/app/app.jar"]
diff --git a/README.md b/README.md
index f91cc67..9c251c7 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,238 @@
-# DesafioTecnicoJavaSpringFullStack
+# 🏦 Desafio Técnico – Bank API
+### **Java + Spring Boot + React + Docker**
+
+API RESTful para um sistema bancário capaz de realizar lançamentos de **débito** e **crédito** em contas de clientes, garantindo **consistência**, **concorrência segura** e uma interface simples para testes.
+
+Inclui:
+
+- ⚙️ Backend em **Java 17 + Spring Boot 3**
+- 🗄 Persistência com **Spring Data JPA + PostgreSQL**
+- 🔐 **Controle de concorrência** com **lock pessimista** (SELECT ... FOR UPDATE) *Optimistic Locking* (`@Version`)
+ - Para evitar condições de corrida (race conditions) em cenários de acesso concorrente à mesma conta, o serviço utiliza um `findByIdForUpdate` no repositório, que faz lock pessimista na linha da conta no banco de dados. Assim, apenas uma transação por vez pode modificar o saldo daquela conta, garantindo consistência.
+- 🌱 **Seeds automáticos** de contas
+- ❗ Tratamento global de erros padronizados
+- 🧪 Testes incluindo **cenários concorrentes**
+- 🐳 **Docker + docker-compose** (app + banco + frontend)
+- 🖥 Frontend em **React + TypeScript + Tailwind**
+
+---
+
+## 📚 Sumário
+
+1. Tecnologias
+2. Arquitetura do Backend
+3. Seeds
+4. Como rodar com Docker
+5. Como rodar localmente
+6. Endpoints da API
+7. Erros
+8. Testes
+
+---
+
+## 🔧 Tecnologias
+
+### **Backend**
+- Java 17+
+- Spring Boot 3
+- Maven 3.9+
+- Docker e Docker Compose (para rodar via containers)
+- Spring Web
+- Spring Data JPA
+- PostgreSQL (se quiser rodar localmente sem Docker)
+- Lombok
+- Springdoc OpenAPI (Swagger)
+
+### **Frontend**
+- React 18
+- TypeScript
+- Vite
+- TailwindCSS
+
+### **Infra**
+- Docker
+- Docker Compose
+
+---
+
+## 🧱 Arquitetura do Backend
+
+```
+src/main/java/com/desafiotecnico/matera
+ ├── MateraApplication.java
+ ├── account/
+ │ ├── api/AccountController.java
+ │ ├── domain/
+ │ │ ├── Account.java
+ │ │ ├── Transaction.java
+ │ │ └── TransactionType.java
+ │ ├── dto/
+ │ │ ├── BalanceResponse.java
+ │ │ ├── CreateAccountRequest.java
+ │ │ ├── TransactionBatchRequest.java
+ │ │ └── TransactionRequest.java
+ │ ├── repository/
+ │ │ ├── AccountRepository.java
+ │ │ └── TransactionRepository.java
+ │ └── service/AccountService.java
+ ├── config
+ │ ├── DataSeeder.java
+ │ ├── CorsConfig.java
+ │ ├── OpenApiConfig.java
+ └── shared/
+ ├── error
+ │ ├──ApiErrorResponse.java
+ │ ├──ErrorResponse.java
+ └── exception/
+ ├── ApiExceptionHandler.java
+ ├── BusinessException.java
+ ├── NotFoundException.java
+ └── InsufficientBalanceException.java
+```
+
+---
+
+## 🌱 Seeds de Dados
+
+Criados automaticamente ao iniciar a aplicação:
+
+| Conta | Número | Saldo inicial |
+|-------|-------------|---------------|
+| Conta 1 | ACC-1001 | 1000.00 + lançamentos |
+| Conta 2 | ACC-2001 | 500.00 |
+| Conta 3 | ACC-3001 | 0.00 |
+
+---
+
+## 🚀 Como rodar tudo com Docker
+
+### 1️⃣ Subir serviços
+
+- Após descompactar diretório, acessar o diretório descompactado e executar o comando:
+
+```
+docker compose up --build
+```
+
+- O banco dentro do Docker responde em db:5432 (interno) mas está exposto em localhost:5435 (externo), caso você queira acessar com DBeaver / psql.
+
+### URLs
+
+- Backend → http://localhost:8080
+- Swagger → http://localhost:8080/swagger-ui/index.html
+- Frontend → http://localhost:8081
+
+---
+
+## ▶️ Rodar Backend localmente (sem Docker)
+
+### Criar banco:
+
+```
+CREATE DATABASE bank;
+CREATE USER bankuser WITH ENCRYPTED PASSWORD 'bankpass';
+GRANT ALL PRIVILEGES ON DATABASE bank TO bankuser;
+```
+
+### Configurar `application.properties`:
+
+```
+spring.datasource.url=jdbc:postgresql://localhost:5432/bank
+spring.datasource.username=bankuser
+spring.datasource.password=bankpass
+spring.jpa.hibernate.ddl-auto=update
+spring.jpa.show-sql=true
+```
+
+### Rodar:
+
+```
+./mvnw spring-boot:run
+```
+
+---
+
+## 🌐 Endpoints da API
+
+- Para facilitar os testes, no diretório postman-collection contem a coleção (Matera.postman_collection.json) de testes realizados via Postman, basta baixar e importar diretamente no aplicativo
+
+### Criar conta
+**POST** `/api/accounts`
+
+```
+{
+ "number": "12345-0",
+ "initialBalance": 1000.00
+}
+```
+
+---
+
+### Listar contas
+**GET** `/api/accounts`
+
+---
+
+### Lançamentos em lote
+**POST** `/api/accounts/{id}/transactions`
+
+```
+{
+ "transactions": [
+ { "type": "DEBIT", "amount": 100.00 },
+ { "type": "CREDIT", "amount": 50.00 }
+ ]
+}
+```
+
+---
+
+### Buscar saldo
+**GET** `/api/accounts/{id}/balance`
+
+---
+
+## ❗ Erros
+
+### Exemplos
+
+Saldo insuficiente:
+
+```
+{
+ "code": "INSUFFICIENT_BALANCE",
+ "message": "Saldo insuficiente."
+}
+```
+
+Conta não encontrada:
+
+```
+{
+ "code": "NOT_FOUND",
+ "message": "Conta não encontrada"
+}
+```
+
+Concorrência:
+
+```
+{
+ "code": "CONCURRENT_MODIFICATION",
+ "message": "A conta foi modificada por outra transação."
+}
+```
+
+---
+
+## 🧪 Testes
+
+```
+./mvnw test
+```
+
+Inclui testes de:
+
+- Débito / Crédito
+- Saldo insuficiente
+- Concorrência
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..f5add8f
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,36 @@
+version: "3.8"
+services:
+ db:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_DB: bank
+ POSTGRES_USER: bankuser
+ POSTGRES_PASSWORD: bankpass
+ ports:
+ - "5435:5432"
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+
+ app:
+ build: .
+ depends_on:
+ - db
+ environment:
+ SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/bank
+ SPRING_DATASOURCE_USERNAME: bankuser
+ SPRING_DATASOURCE_PASSWORD: bankpass
+ SPRING_JPA_HIBERNATE_DDL_AUTO: update
+ ports:
+ - "8080:8080"
+
+ frontend:
+ build: ./matera-frontend
+ depends_on:
+ - app
+ environment:
+ - VITE_API_BASE_URL=http://app:8080
+ ports:
+ - "8081:5173"
+
+volumes:
+ pgdata:
diff --git a/matera-frontend/.gitignore b/matera-frontend/.gitignore
new file mode 100644
index 0000000..424ff50
--- /dev/null
+++ b/matera-frontend/.gitignore
@@ -0,0 +1,87 @@
+# ───────────────────────────────
+# Node / Core
+# ───────────────────────────────
+node_modules/
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+package-lock.json
+.pnp
+.pnp.js
+
+# ───────────────────────────────
+# Next.js build output
+# ───────────────────────────────
+.next/
+out/
+dist/
+
+# ───────────────────────────────
+# Env files
+# ───────────────────────────────
+.env
+.env*.local
+
+# ───────────────────────────────
+# Logs
+# ───────────────────────────────
+logs
+*.log
+*.csv
+*.tsv
+*.pid
+*.seed
+*.pid.lock
+
+# ───────────────────────────────
+# OS / System files
+# ───────────────────────────────
+.DS_Store
+Thumbs.db
+
+# ───────────────────────────────
+# Editor / IDE configs
+# ───────────────────────────────
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+
+.idea/
+*.iml
+
+# ───────────────────────────────
+# Test / Coverage
+# ───────────────────────────────
+coverage/
+.nyc_output/
+
+# ───────────────────────────────
+# TypeScript
+# ───────────────────────────────
+*.tsbuildinfo
+next-env.d.ts
+
+# ───────────────────────────────
+# Runtime
+# ───────────────────────────────
+*.local
+
+# ───────────────────────────────
+# Vercel
+# ───────────────────────────────
+.vercel/
+
+# ───────────────────────────────
+# Storybook (opcional)
+# ───────────────────────────────
+storybook-static/
+
+# ───────────────────────────────
+# Misc
+# ───────────────────────────────
+*.swp
+*.swo
+*.zip
diff --git a/matera-frontend/Dockerfile b/matera-frontend/Dockerfile
new file mode 100644
index 0000000..1619b41
--- /dev/null
+++ b/matera-frontend/Dockerfile
@@ -0,0 +1,20 @@
+FROM node:20-alpine AS build
+WORKDIR /app
+
+COPY package*.json ./
+RUN npm install
+
+COPY . .
+RUN npm run build
+
+FROM node:20-alpine
+WORKDIR /app
+
+COPY package*.json ./
+RUN npm install
+
+COPY . .
+
+EXPOSE 5173
+
+CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
diff --git a/matera-frontend/README.md b/matera-frontend/README.md
new file mode 100644
index 0000000..10527cd
--- /dev/null
+++ b/matera-frontend/README.md
@@ -0,0 +1,63 @@
+# 💳 Frontend – Bank API
+
+Interface web em **React + TypeScript + Vite + TailwindCSS** para consumir a Bank API desenvolvida em Spring Boot.
+
+O objetivo é permitir que a pessoa usuária:
+
+- visualize as contas disponíveis
+- consulte saldos
+- simule lançamentos de **débito** e **crédito** em uma conta
+
+---
+
+## 🧰 Tecnologias
+
+- **React 18**
+- **TypeScript**
+- **Vite**
+- **TailwindCSS**
+- Integração com backend em **Spring Boot** via HTTP
+
+---
+
+## ✅ Pré-requisitos
+
+Para rodar localmente:
+
+- Node.js 18+ (ou 20+)
+- npm ou yarn
+- Backend da Bank API rodando (ex.: em `http://localhost:8080`)
+
+Para rodar via Docker:
+
+- Docker
+- Docker Compose
+- Backend e banco já estão orquestrados pelo `docker-compose.yml` na raiz do projeto
+
+---
+
+## ⚙️ Variáveis de ambiente
+
+O frontend utiliza uma variável para apontar para a API:
+
+- `VITE_API_BASE_URL`
+
+### Exemplos de uso
+
+- Ambiente local (sem Docker):
+
+```bash
+ VITE_API_BASE_URL=http://localhost:8080
+```
+
+### ▶️ Rodando localmente (sem Docker):
+
+#### Na pasta matera-frontend:
+
+- instalar dependências e rodar servidor de desenvolvimento
+```bash
+ npm install
+ npm run dev
+```
+
+- Por padrão, o Vite sobe em: http://localhost:5173/
\ No newline at end of file
diff --git a/matera-frontend/index.html b/matera-frontend/index.html
new file mode 100644
index 0000000..ab6bdab
--- /dev/null
+++ b/matera-frontend/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+ Bank UI - Desafio Matera
+
+
+
+
+
+
+
diff --git a/matera-frontend/package.json b/matera-frontend/package.json
new file mode 100644
index 0000000..0602247
--- /dev/null
+++ b/matera-frontend/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "matera-frontend",
+ "version": "0.0.1",
+ "private": true,
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "axios": "^1.7.0",
+ "react": "^18.3.0",
+ "react-dom": "^18.3.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.0",
+ "@types/react-dom": "^18.3.0",
+ "@vitejs/plugin-react-swc": "^3.7.0",
+ "autoprefixer": "^10.4.19",
+ "postcss": "^8.4.35",
+ "tailwindcss": "^3.4.4",
+ "typescript": "^5.4.0",
+ "vite": "^5.0.0"
+ }
+}
diff --git a/matera-frontend/postcss.config.cjs b/matera-frontend/postcss.config.cjs
new file mode 100644
index 0000000..5cbc2c7
--- /dev/null
+++ b/matera-frontend/postcss.config.cjs
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {}
+ }
+};
diff --git a/matera-frontend/src/App.tsx b/matera-frontend/src/App.tsx
new file mode 100644
index 0000000..59ecfc2
--- /dev/null
+++ b/matera-frontend/src/App.tsx
@@ -0,0 +1,104 @@
+import React, { useEffect, useState } from "react";
+import type { AccountSummary } from "./types";
+import { fetchAccounts } from "./api/client";
+import { AccountList } from "./components/AccountList";
+import { AccountForm } from "./components/AccountForm";
+import { TransactionsForm } from "./components/TransactionsForm";
+import { BalanceCard } from "./components/BalanceCard";
+
+const API_BASE =
+ import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080/api";
+
+const App: React.FC = () => {
+ const [accounts, setAccounts] = useState([]);
+ const [selectedId, setSelectedId] = useState();
+ const [loadingAccounts, setLoadingAccounts] = useState(false);
+
+ const loadAccounts = async () => {
+ setLoadingAccounts(true);
+ try {
+ const data = await fetchAccounts();
+ setAccounts(data);
+ if (!selectedId && data.length > 0) {
+ setSelectedId(data[0].accountId);
+ }
+ } catch (err) {
+ console.error("Erro ao carregar contas", err);
+ } finally {
+ setLoadingAccounts(false);
+ }
+ };
+
+ useEffect(() => {
+ void loadAccounts();
+ }, []);
+
+ const selectedAccount = accounts.find((a) => a.accountId === selectedId);
+
+ return (
+
+
+
+
+
Bank UI – Desafio Matera
+
+ Lançamentos bancários com Java + Spring Boot + React
+
+
+
+ API: {API_BASE}
+
+
+
+
+
+
+
+
+
Contas
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Como usar
+
+ - Use o formulário para criar uma nova conta, se quiser.
+ - Selecione uma conta na lista à esquerda.
+ - Veja o saldo atual no card de saldo.
+ -
+ Use o formulário de lançamentos para adicionar débitos e
+ créditos em lote.
+
+ -
+ Clique em "Atualizar lista" para ver o saldo
+ atualizado.
+
+
+
+
+
+
+ );
+};
+
+export default App;
diff --git a/matera-frontend/src/api/client.ts b/matera-frontend/src/api/client.ts
new file mode 100644
index 0000000..fb57eca
--- /dev/null
+++ b/matera-frontend/src/api/client.ts
@@ -0,0 +1,32 @@
+import axios from "axios";
+import type {
+ AccountSummary,
+ CreateAccountPayload,
+ TransactionBatchPayload
+} from "../types";
+
+const API_BASE =
+ import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080/api";
+
+export const api = axios.create({
+ baseURL: API_BASE,
+ headers: {
+ "Content-Type": "application/json"
+ }
+});
+
+export async function fetchAccounts(): Promise {
+ const { data } = await api.get("/accounts");
+ return data;
+}
+
+export async function createAccount(payload: CreateAccountPayload): Promise {
+ await api.post("/accounts", payload);
+}
+
+export async function applyTransactions(
+ accountId: string,
+ payload: TransactionBatchPayload
+): Promise {
+ await api.post(`/accounts/${accountId}/transactions`, payload);
+}
diff --git a/matera-frontend/src/components/AccountForm.tsx b/matera-frontend/src/components/AccountForm.tsx
new file mode 100644
index 0000000..21e7541
--- /dev/null
+++ b/matera-frontend/src/components/AccountForm.tsx
@@ -0,0 +1,79 @@
+import React, { useState } from "react";
+import { createAccount } from "../api/client";
+
+interface Props {
+ onCreated: () => void;
+}
+
+export const AccountForm: React.FC = ({ onCreated }) => {
+ const [number, setNumber] = useState("");
+ const [initialBalance, setInitialBalance] = useState("0");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setLoading(true);
+ setError(null);
+ try {
+ const payload = {
+ number,
+ initialBalance: parseFloat(initialBalance || "0")
+ };
+ await createAccount(payload);
+ setNumber("");
+ setInitialBalance("0");
+ onCreated();
+ } catch (err: any) {
+ const message =
+ err?.response?.data?.message ?? "Erro ao criar conta";
+ setError(message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ );
+};
diff --git a/matera-frontend/src/components/AccountList.tsx b/matera-frontend/src/components/AccountList.tsx
new file mode 100644
index 0000000..0fe12bf
--- /dev/null
+++ b/matera-frontend/src/components/AccountList.tsx
@@ -0,0 +1,49 @@
+import React from "react";
+import type { AccountSummary } from "../types";
+
+interface Props {
+ accounts: AccountSummary[];
+ selectedId?: string;
+ onSelect: (accountId: string) => void;
+}
+
+export const AccountList: React.FC = ({
+ accounts,
+ selectedId,
+ onSelect
+}) => {
+ return (
+
+
Contas disponíveis
+ {accounts.length === 0 ? (
+
+ Nenhuma conta encontrada. Crie uma nova conta ao lado.
+
+ ) : (
+
+ )}
+
+ );
+};
diff --git a/matera-frontend/src/components/BalanceCard.tsx b/matera-frontend/src/components/BalanceCard.tsx
new file mode 100644
index 0000000..51d3075
--- /dev/null
+++ b/matera-frontend/src/components/BalanceCard.tsx
@@ -0,0 +1,30 @@
+import React from "react";
+import type { AccountSummary } from "../types";
+
+interface Props {
+ account?: AccountSummary;
+}
+
+export const BalanceCard: React.FC = ({ account }) => {
+ if (!account) {
+ return (
+
+
+ Selecione uma conta para visualizar o saldo.
+
+
+ );
+ }
+
+ return (
+
+
+ Saldo da conta {account.number}
+
+
+ R$ {account.balance.toFixed(2)}
+
+
ID: {account.accountId}
+
+ );
+};
diff --git a/matera-frontend/src/components/TransactionsForm.tsx b/matera-frontend/src/components/TransactionsForm.tsx
new file mode 100644
index 0000000..45021b5
--- /dev/null
+++ b/matera-frontend/src/components/TransactionsForm.tsx
@@ -0,0 +1,133 @@
+import React, { useState } from "react";
+import type { TransactionItem, TransactionType } from "../types";
+import { applyTransactions } from "../api/client";
+
+interface Props {
+ accountId?: string;
+ onApplied: () => void;
+}
+
+export const TransactionsForm: React.FC = ({
+ accountId,
+ onApplied
+}) => {
+ const [items, setItems] = useState([
+ { type: "DEBIT", amount: 0 }
+ ]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const updateItem = (index: number, patch: Partial) => {
+ setItems((prev) =>
+ prev.map((item, i) => (i === index ? { ...item, ...patch } : item))
+ );
+ };
+
+ const addItem = () => {
+ setItems((prev) => [...prev, { type: "CREDIT", amount: 0 }]);
+ };
+
+ const removeItem = (index: number) => {
+ setItems((prev) => prev.filter((_, i) => i !== index));
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!accountId) {
+ setError("Selecione uma conta primeiro.");
+ return;
+ }
+ setLoading(true);
+ setError(null);
+ try {
+ const payload = {
+ transactions: items.map((i) => ({
+ type: i.type,
+ amount: i.amount
+ }))
+ };
+ await applyTransactions(accountId, payload);
+ onApplied();
+ } catch (err: any) {
+ const message =
+ err?.response?.data?.message ?? "Erro ao aplicar lançamentos";
+ setError(message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
Lançar débitos/créditos
+ {!accountId && (
+
+ Selecione uma conta para aplicar lançamentos.
+
+ )}
+
+
+ );
+};
diff --git a/matera-frontend/src/index.css b/matera-frontend/src/index.css
new file mode 100644
index 0000000..25ecdba
--- /dev/null
+++ b/matera-frontend/src/index.css
@@ -0,0 +1,8 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+body {
+ @apply bg-slate-100;
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
diff --git a/matera-frontend/src/main.tsx b/matera-frontend/src/main.tsx
new file mode 100644
index 0000000..02055fd
--- /dev/null
+++ b/matera-frontend/src/main.tsx
@@ -0,0 +1,10 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import App from "./App";
+import "./index.css";
+
+ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
+
+
+
+);
diff --git a/matera-frontend/src/types.ts b/matera-frontend/src/types.ts
new file mode 100644
index 0000000..c3333af
--- /dev/null
+++ b/matera-frontend/src/types.ts
@@ -0,0 +1,26 @@
+export interface AccountSummary {
+ accountId: string;
+ number: string;
+ balance: number;
+}
+
+export interface CreateAccountPayload {
+ number: string;
+ initialBalance: number;
+}
+
+export type TransactionType = "DEBIT" | "CREDIT";
+
+export interface TransactionItem {
+ type: TransactionType;
+ amount: number;
+}
+
+export interface TransactionBatchPayload {
+ transactions: TransactionItem[];
+}
+
+export interface ApiErrorResponse {
+ code: string;
+ message: string;
+}
diff --git a/matera-frontend/tailwind.config.cjs b/matera-frontend/tailwind.config.cjs
new file mode 100644
index 0000000..e7d5348
--- /dev/null
+++ b/matera-frontend/tailwind.config.cjs
@@ -0,0 +1,7 @@
+module.exports = {
+ content: ["./index.html", "./src/**/*.{ts,tsx}"],
+ theme: {
+ extend: {}
+ },
+ plugins: []
+};
diff --git a/matera-frontend/tsconfig.json b/matera-frontend/tsconfig.json
new file mode 100644
index 0000000..5eabbe2
--- /dev/null
+++ b/matera-frontend/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "types": ["vite/client"]
+ },
+ "include": ["src"]
+}
diff --git a/matera-frontend/vite.config.ts b/matera-frontend/vite.config.ts
new file mode 100644
index 0000000..f3a841e
--- /dev/null
+++ b/matera-frontend/vite.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react-swc";
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5173
+ }
+});
diff --git a/mvnw b/mvnw
new file mode 100755
index 0000000..bd8896b
--- /dev/null
+++ b/mvnw
@@ -0,0 +1,295 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Apache Maven Wrapper startup batch script, version 3.3.4
+#
+# Optional ENV vars
+# -----------------
+# JAVA_HOME - location of a JDK home dir, required when download maven via java source
+# MVNW_REPOURL - repo url base for downloading maven distribution
+# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
+# ----------------------------------------------------------------------------
+
+set -euf
+[ "${MVNW_VERBOSE-}" != debug ] || set -x
+
+# OS specific support.
+native_path() { printf %s\\n "$1"; }
+case "$(uname)" in
+CYGWIN* | MINGW*)
+ [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
+ native_path() { cygpath --path --windows "$1"; }
+ ;;
+esac
+
+# set JAVACMD and JAVACCMD
+set_java_home() {
+ # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
+ if [ -n "${JAVA_HOME-}" ]; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ]; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ JAVACCMD="$JAVA_HOME/jre/sh/javac"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ JAVACCMD="$JAVA_HOME/bin/javac"
+
+ if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
+ echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
+ echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
+ return 1
+ fi
+ fi
+ else
+ JAVACMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v java
+ )" || :
+ JAVACCMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v javac
+ )" || :
+
+ if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
+ echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
+ return 1
+ fi
+ fi
+}
+
+# hash string like Java String::hashCode
+hash_string() {
+ str="${1:-}" h=0
+ while [ -n "$str" ]; do
+ char="${str%"${str#?}"}"
+ h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
+ str="${str#?}"
+ done
+ printf %x\\n $h
+}
+
+verbose() { :; }
+[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
+
+die() {
+ printf %s\\n "$1" >&2
+ exit 1
+}
+
+trim() {
+ # MWRAPPER-139:
+ # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
+ # Needed for removing poorly interpreted newline sequences when running in more
+ # exotic environments such as mingw bash on Windows.
+ printf "%s" "${1}" | tr -d '[:space:]'
+}
+
+scriptDir="$(dirname "$0")"
+scriptName="$(basename "$0")"
+
+# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
+while IFS="=" read -r key value; do
+ case "${key-}" in
+ distributionUrl) distributionUrl=$(trim "${value-}") ;;
+ distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
+ esac
+done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
+[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+
+case "${distributionUrl##*/}" in
+maven-mvnd-*bin.*)
+ MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
+ case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
+ *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
+ :Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
+ :Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
+ :Linux*x86_64*) distributionPlatform=linux-amd64 ;;
+ *)
+ echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
+ distributionPlatform=linux-amd64
+ ;;
+ esac
+ distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
+ ;;
+maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
+*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
+esac
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
+distributionUrlName="${distributionUrl##*/}"
+distributionUrlNameMain="${distributionUrlName%.*}"
+distributionUrlNameMain="${distributionUrlNameMain%-bin}"
+MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
+MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
+
+exec_maven() {
+ unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
+ exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
+}
+
+if [ -d "$MAVEN_HOME" ]; then
+ verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ exec_maven "$@"
+fi
+
+case "${distributionUrl-}" in
+*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
+*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
+esac
+
+# prepare tmp dir
+if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
+ clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
+ trap clean HUP INT TERM EXIT
+else
+ die "cannot create temp dir"
+fi
+
+mkdir -p -- "${MAVEN_HOME%/*}"
+
+# Download and Install Apache Maven
+verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+verbose "Downloading from: $distributionUrl"
+verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+# select .zip or .tar.gz
+if ! command -v unzip >/dev/null; then
+ distributionUrl="${distributionUrl%.zip}.tar.gz"
+ distributionUrlName="${distributionUrl##*/}"
+fi
+
+# verbose opt
+__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
+[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
+
+# normalize http auth
+case "${MVNW_PASSWORD:+has-password}" in
+'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+esac
+
+if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
+ verbose "Found wget ... using wget"
+ wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
+elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
+ verbose "Found curl ... using curl"
+ curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
+elif set_java_home; then
+ verbose "Falling back to use Java to download"
+ javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
+ targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
+ cat >"$javaSource" <<-END
+ public class Downloader extends java.net.Authenticator
+ {
+ protected java.net.PasswordAuthentication getPasswordAuthentication()
+ {
+ return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
+ }
+ public static void main( String[] args ) throws Exception
+ {
+ setDefault( new Downloader() );
+ java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
+ }
+ }
+ END
+ # For Cygwin/MinGW, switch paths to Windows format before running javac and java
+ verbose " - Compiling Downloader.java ..."
+ "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
+ verbose " - Running Downloader.java ..."
+ "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
+fi
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+if [ -n "${distributionSha256Sum-}" ]; then
+ distributionSha256Result=false
+ if [ "$MVN_CMD" = mvnd.sh ]; then
+ echo "Checksum validation is not supported for maven-mvnd." >&2
+ echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ elif command -v sha256sum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ elif command -v shasum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ else
+ echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
+ echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ fi
+ if [ $distributionSha256Result = false ]; then
+ echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
+ echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
+ exit 1
+ fi
+fi
+
+# unzip and move
+if command -v unzip >/dev/null; then
+ unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
+else
+ tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
+fi
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+actualDistributionDir=""
+
+# First try the expected directory name (for regular distributions)
+if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
+ if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$distributionUrlNameMain"
+ fi
+fi
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if [ -z "$actualDistributionDir" ]; then
+ # enable globbing to iterate over items
+ set +f
+ for dir in "$TMP_DOWNLOAD_DIR"/*; do
+ if [ -d "$dir" ]; then
+ if [ -f "$dir/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$(basename "$dir")"
+ break
+ fi
+ fi
+ done
+ set -f
+fi
+
+if [ -z "$actualDistributionDir" ]; then
+ verbose "Contents of $TMP_DOWNLOAD_DIR:"
+ verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
+ die "Could not find Maven distribution directory in extracted archive"
+fi
+
+verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
+mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
+
+clean || :
+exec_maven "$@"
diff --git a/mvnw.cmd b/mvnw.cmd
new file mode 100644
index 0000000..92450f9
--- /dev/null
+++ b/mvnw.cmd
@@ -0,0 +1,189 @@
+<# : batch portion
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Apache Maven Wrapper startup batch script, version 3.3.4
+@REM
+@REM Optional ENV vars
+@REM MVNW_REPOURL - repo url base for downloading maven distribution
+@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
+@REM ----------------------------------------------------------------------------
+
+@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
+@SET __MVNW_CMD__=
+@SET __MVNW_ERROR__=
+@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
+@SET PSModulePath=
+@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
+ IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
+)
+@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
+@SET __MVNW_PSMODULEP_SAVE=
+@SET __MVNW_ARG0_NAME__=
+@SET MVNW_USERNAME=
+@SET MVNW_PASSWORD=
+@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
+@echo Cannot start maven from wrapper >&2 && exit /b 1
+@GOTO :EOF
+: end batch / begin powershell #>
+
+$ErrorActionPreference = "Stop"
+if ($env:MVNW_VERBOSE -eq "true") {
+ $VerbosePreference = "Continue"
+}
+
+# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
+$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
+if (!$distributionUrl) {
+ Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+}
+
+switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
+ "maven-mvnd-*" {
+ $USE_MVND = $true
+ $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
+ $MVN_CMD = "mvnd.cmd"
+ break
+ }
+ default {
+ $USE_MVND = $false
+ $MVN_CMD = $script -replace '^mvnw','mvn'
+ break
+ }
+}
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+if ($env:MVNW_REPOURL) {
+ $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
+ $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
+}
+$distributionUrlName = $distributionUrl -replace '^.*/',''
+$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
+
+$MAVEN_M2_PATH = "$HOME/.m2"
+if ($env:MAVEN_USER_HOME) {
+ $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
+}
+
+if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
+ New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
+}
+
+$MAVEN_WRAPPER_DISTS = $null
+if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
+ $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
+} else {
+ $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
+}
+
+$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
+$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
+$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
+
+if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
+ Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+ exit $?
+}
+
+if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
+ Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
+}
+
+# prepare tmp dir
+$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
+$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
+$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
+trap {
+ if ($TMP_DOWNLOAD_DIR.Exists) {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+ }
+}
+
+New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
+
+# Download and Install Apache Maven
+Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+Write-Verbose "Downloading from: $distributionUrl"
+Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+$webclient = New-Object System.Net.WebClient
+if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
+ $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
+}
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
+if ($distributionSha256Sum) {
+ if ($USE_MVND) {
+ Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
+ }
+ Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
+ if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
+ Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
+ }
+}
+
+# unzip and move
+Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+$actualDistributionDir = ""
+
+# First try the expected directory name (for regular distributions)
+$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
+$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
+if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
+ $actualDistributionDir = $distributionUrlNameMain
+}
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if (!$actualDistributionDir) {
+ Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
+ $testPath = Join-Path $_.FullName "bin/$MVN_CMD"
+ if (Test-Path -Path $testPath -PathType Leaf) {
+ $actualDistributionDir = $_.Name
+ }
+ }
+}
+
+if (!$actualDistributionDir) {
+ Write-Error "Could not find Maven distribution directory in extracted archive"
+}
+
+Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
+try {
+ Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
+} catch {
+ if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
+ Write-Error "fail to move MAVEN_HOME"
+ }
+} finally {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+}
+
+Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
diff --git a/pom.xml b/pom.xml
index 1f94a29..155f2fb 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1,50 +1,39 @@
-
-
+
4.0.0
+
org.springframework.boot
spring-boot-starter-parent
- 4.0.0
-
+ 3.3.5
+
+
com.desafiotecnico
matera
0.0.1-SNAPSHOT
matera
- Demo project for Spring Boot
-
-
-
-
-
-
-
-
-
-
-
-
-
+ Desafio técnico – Bank API
+
17
+
org.springframework.boot
spring-boot-starter-data-jpa
-
- org.springframework.boot
- spring-boot-starter-hateoas
-
+
org.springframework.boot
spring-boot-starter-validation
+
org.springframework.boot
- spring-boot-starter-webmvc
+ spring-boot-starter-web
@@ -53,39 +42,34 @@
runtime
true
+
com.h2database
h2
runtime
+
org.postgresql
postgresql
runtime
+
org.projectlombok
lombok
true
+
- org.springframework.boot
- spring-boot-starter-data-jpa-test
- test
-
-
- org.springframework.boot
- spring-boot-starter-hateoas-test
- test
-
-
- org.springframework.boot
- spring-boot-starter-validation-test
- test
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+ 2.5.0
+
org.springframework.boot
- spring-boot-starter-webmvc-test
+ spring-boot-starter-test
test
@@ -104,6 +88,7 @@
+
org.springframework.boot
spring-boot-maven-plugin
@@ -118,5 +103,4 @@
-
diff --git a/postman-collection/Matera.postman_collection.json b/postman-collection/Matera.postman_collection.json
new file mode 100644
index 0000000..f8065a8
--- /dev/null
+++ b/postman-collection/Matera.postman_collection.json
@@ -0,0 +1,148 @@
+{
+ "info": {
+ "_postman_id": "91577d9c-1f03-4296-8b31-5b2d96777502",
+ "name": "Matera",
+ "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
+ "_exporter_id": "37957167"
+ },
+ "item": [
+ {
+ "name": "Create account",
+ "request": {
+ "auth": {
+ "type": "noauth"
+ },
+ "method": "POST",
+ "header": [],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"number\": \"12345-0\",\n \"initialBalance\": 1000.00\n}\n",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "http://localhost:8080/api/accounts",
+ "protocol": "http",
+ "host": [
+ "localhost"
+ ],
+ "port": "8080",
+ "path": [
+ "api",
+ "accounts"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "List all accounts",
+ "protocolProfileBehavior": {
+ "disableBodyPruning": true
+ },
+ "request": {
+ "auth": {
+ "type": "noauth"
+ },
+ "method": "GET",
+ "header": [],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"number\": \"12345-0\",\n \"initialBalance\": 1000.00\n}\n",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "http://localhost:8080/api/accounts",
+ "protocol": "http",
+ "host": [
+ "localhost"
+ ],
+ "port": "8080",
+ "path": [
+ "api",
+ "accounts"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Make Transactions account",
+ "request": {
+ "auth": {
+ "type": "noauth"
+ },
+ "method": "POST",
+ "header": [],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"transactions\": [\n { \"type\": \"DEBIT\", \"amount\": 100.00 },\n { \"type\": \"CREDIT\", \"amount\": 50.00 }\n ]\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "http://localhost:8080/api/accounts/af83ba62-3e30-44b5-bf47-fa91cc9dc0a4/transactions",
+ "protocol": "http",
+ "host": [
+ "localhost"
+ ],
+ "port": "8080",
+ "path": [
+ "api",
+ "accounts",
+ "af83ba62-3e30-44b5-bf47-fa91cc9dc0a4",
+ "transactions"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Get Balance from account by id",
+ "protocolProfileBehavior": {
+ "disableBodyPruning": true
+ },
+ "request": {
+ "auth": {
+ "type": "noauth"
+ },
+ "method": "GET",
+ "header": [],
+ "body": {
+ "mode": "raw",
+ "raw": "",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": {
+ "raw": "http://localhost:8080/api/accounts/af83ba62-3e30-44b5-bf47-fa91cc9dc0a4/balance",
+ "protocol": "http",
+ "host": [
+ "localhost"
+ ],
+ "port": "8080",
+ "path": [
+ "api",
+ "accounts",
+ "af83ba62-3e30-44b5-bf47-fa91cc9dc0a4",
+ "balance"
+ ]
+ }
+ },
+ "response": []
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/main/java/com/desafiotecnico/matera/account/api/AccountController.java b/src/main/java/com/desafiotecnico/matera/account/api/AccountController.java
new file mode 100644
index 0000000..f6e5c7e
--- /dev/null
+++ b/src/main/java/com/desafiotecnico/matera/account/api/AccountController.java
@@ -0,0 +1,118 @@
+package com.desafiotecnico.matera.account.api;
+
+import com.desafiotecnico.matera.account.domain.Account;
+import com.desafiotecnico.matera.account.dto.BalanceResponse;
+import com.desafiotecnico.matera.account.dto.CreateAccountRequest;
+import com.desafiotecnico.matera.account.dto.TransactionBatchRequest;
+import com.desafiotecnico.matera.account.service.AccountService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+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.net.URI;
+import java.util.List;
+
+@Tag(
+ name = "Accounts",
+ description = "Operações de contas bancárias: criação, consulta de saldo e lançamentos de débito/crédito."
+)
+@RestController
+@RequestMapping("/api/accounts")
+@RequiredArgsConstructor
+public class AccountController {
+ private final AccountService accountService;
+
+ @Operation(
+ summary = "Criar conta bancária",
+ description = "Cria uma nova conta bancária com número único e saldo inicial opcional."
+ )
+ @ApiResponse(
+ responseCode = "201",
+ description = "Conta criada com sucesso",
+ content = @Content(
+ mediaType = "application/json",
+ schema = @Schema(implementation = BalanceResponse.class)
+ )
+ )
+
+ @PostMapping
+ public ResponseEntity create(@Valid @RequestBody CreateAccountRequest request) {
+ Account account = accountService.createAccount(request);
+
+ BalanceResponse body = new BalanceResponse(
+ account.getId(),
+ account.getNumber(),
+ account.getBalance()
+ );
+
+ URI location = URI.create("/api/accounts/" + account.getId());
+
+ return ResponseEntity
+ .created(location)
+ .body(body);
+ }
+
+
+ @Operation(
+ summary = "Aplicar lançamentos em lote",
+ description = """
+ Aplica um ou mais lançamentos de débito/crédito em uma conta específica.
+ A operação é realizada de forma transacional e thread-safe, garantindo consistência de saldo em cenários concorrentes.
+ """
+ )
+ @ApiResponse(
+ responseCode = "204",
+ description = "Lançamentos aplicados com sucesso"
+ )
+ @ApiResponse(
+ responseCode = "404",
+ description = "Conta não encontrada",
+ content = @Content(schema = @Schema(implementation = com.desafiotecnico.matera.shared.error.ApiErrorResponse.class))
+ )
+ @ApiResponse(
+ responseCode = "422",
+ description = "Saldo insuficiente",
+ content = @Content(schema = @Schema(implementation = com.desafiotecnico.matera.shared.error.ApiErrorResponse.class))
+ )
+ @PostMapping("/{id}/transactions")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void applyTransactions(
+ @Parameter(description = "ID da conta (UUID)")
+ @PathVariable String id,
+ @Valid @RequestBody TransactionBatchRequest batch) {
+ accountService.applyTransactionsWithRetry(id, batch);
+ }
+
+ @Operation(
+ summary = "Obter saldo atual",
+ description = "Retorna apenas o saldo atual da conta informada. Atalho para consultas rápidas."
+ )
+ @GetMapping("/{id}/balance")
+ public BalanceResponse getBalance(
+ @Parameter(description = "ID da conta (UUID)")
+ @PathVariable String id)
+ {
+ return accountService.getBalance(id);
+ }
+
+ @Operation(
+ summary = "Listar contas",
+ description = "Retorna todas as contas cadastradas, incluindo as contas seed."
+ )
+ @ApiResponse(
+ responseCode = "200",
+ description = "Lista de contas retornada com sucesso"
+ )
+ @GetMapping
+ public List listAccounts() {
+ return accountService.listAccounts();
+ }
+}
diff --git a/src/main/java/com/desafiotecnico/matera/account/domain/Account.java b/src/main/java/com/desafiotecnico/matera/account/domain/Account.java
new file mode 100644
index 0000000..41fd23c
--- /dev/null
+++ b/src/main/java/com/desafiotecnico/matera/account/domain/Account.java
@@ -0,0 +1,71 @@
+package com.desafiotecnico.matera.account.domain;
+
+import com.desafiotecnico.matera.shared.exception.InsufficientBalanceException;
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+
+@Entity
+@Table(name = "accounts")
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class Account {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ private String id;
+
+ @Column(nullable = false, unique = true)
+ private String number;
+
+ @Column(nullable = false, precision = 19, scale = 2)
+ private BigDecimal balance;
+
+ @Version
+ private Long version;
+
+ @Column(nullable = false, updatable = false)
+ private Instant createdAt;
+
+ @Column(nullable = false)
+ private Instant updatedAt;
+
+ @PrePersist
+ void prePersist() {
+ Instant now = Instant.now();
+ createdAt = now;
+ updatedAt = now;
+ if (balance == null) {
+ balance = BigDecimal.ZERO;
+ }
+ }
+
+ @PreUpdate
+ void preUpdate() {
+ updatedAt = Instant.now();
+ }
+
+ public void credit(BigDecimal amount) {
+ validateAmount(amount);
+ this.balance = this.balance.add(amount);
+ }
+
+ public void debit(BigDecimal amount) {
+ validateAmount(amount);
+ if (this.balance.compareTo(amount) < 0) {
+ throw new InsufficientBalanceException("Saldo insuficiente.");
+ }
+ this.balance = this.balance.subtract(amount);
+ }
+
+ private void validateAmount(BigDecimal amount) {
+ if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
+ throw new IllegalArgumentException("Valor deve ser maior que zero.");
+ }
+ }
+}
diff --git a/src/main/java/com/desafiotecnico/matera/account/domain/Transaction.java b/src/main/java/com/desafiotecnico/matera/account/domain/Transaction.java
new file mode 100644
index 0000000..37428bc
--- /dev/null
+++ b/src/main/java/com/desafiotecnico/matera/account/domain/Transaction.java
@@ -0,0 +1,40 @@
+package com.desafiotecnico.matera.account.domain;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+
+@Entity
+@Table(name = "transactions")
+@Getter @Setter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class Transaction {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(optional = false, fetch = FetchType.LAZY)
+ @JoinColumn(name = "account_id")
+ private Account account;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ private TransactionType type;
+
+ @Column(nullable = false, precision = 19, scale = 2)
+ private BigDecimal amount;
+
+ @Column(nullable = false, updatable = false)
+ private Instant createdAt;
+
+ @PrePersist
+ public void prePersist() {
+ if (createdAt == null) {
+ createdAt = Instant.now();
+ }
+ }
+}
diff --git a/src/main/java/com/desafiotecnico/matera/account/domain/TransactionType.java b/src/main/java/com/desafiotecnico/matera/account/domain/TransactionType.java
new file mode 100644
index 0000000..34f001e
--- /dev/null
+++ b/src/main/java/com/desafiotecnico/matera/account/domain/TransactionType.java
@@ -0,0 +1,5 @@
+package com.desafiotecnico.matera.account.domain;
+
+public enum TransactionType {
+ DEBIT, CREDIT
+}
diff --git a/src/main/java/com/desafiotecnico/matera/account/dto/BalanceResponse.java b/src/main/java/com/desafiotecnico/matera/account/dto/BalanceResponse.java
new file mode 100644
index 0000000..671f02f
--- /dev/null
+++ b/src/main/java/com/desafiotecnico/matera/account/dto/BalanceResponse.java
@@ -0,0 +1,11 @@
+package com.desafiotecnico.matera.account.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.math.BigDecimal;
+
+public record BalanceResponse (
+ @JsonProperty("id") String accountId,
+ String number,
+ BigDecimal balance
+) {}
\ No newline at end of file
diff --git a/src/main/java/com/desafiotecnico/matera/account/dto/CreateAccountRequest.java b/src/main/java/com/desafiotecnico/matera/account/dto/CreateAccountRequest.java
new file mode 100644
index 0000000..65da1fb
--- /dev/null
+++ b/src/main/java/com/desafiotecnico/matera/account/dto/CreateAccountRequest.java
@@ -0,0 +1,11 @@
+package com.desafiotecnico.matera.account.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.PositiveOrZero;
+
+import java.math.BigDecimal;
+
+public record CreateAccountRequest (
+ @NotBlank String number,
+ @PositiveOrZero BigDecimal initialBalance
+){}
diff --git a/src/main/java/com/desafiotecnico/matera/account/dto/TransactionBatchRequest.java b/src/main/java/com/desafiotecnico/matera/account/dto/TransactionBatchRequest.java
new file mode 100644
index 0000000..e605165
--- /dev/null
+++ b/src/main/java/com/desafiotecnico/matera/account/dto/TransactionBatchRequest.java
@@ -0,0 +1,10 @@
+package com.desafiotecnico.matera.account.dto;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotEmpty;
+
+import java.util.List;
+
+public record TransactionBatchRequest (
+ @NotEmpty List<@Valid TransactionRequest> transactions
+) {}
\ No newline at end of file
diff --git a/src/main/java/com/desafiotecnico/matera/account/dto/TransactionRequest.java b/src/main/java/com/desafiotecnico/matera/account/dto/TransactionRequest.java
new file mode 100644
index 0000000..e4dc7cd
--- /dev/null
+++ b/src/main/java/com/desafiotecnico/matera/account/dto/TransactionRequest.java
@@ -0,0 +1,12 @@
+package com.desafiotecnico.matera.account.dto;
+
+import com.desafiotecnico.matera.account.domain.TransactionType;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
+
+import java.math.BigDecimal;
+
+public record TransactionRequest (
+ @NotNull TransactionType type,
+ @NotNull @Positive BigDecimal amount
+) {}
diff --git a/src/main/java/com/desafiotecnico/matera/account/repository/AccountRepository.java b/src/main/java/com/desafiotecnico/matera/account/repository/AccountRepository.java
new file mode 100644
index 0000000..ce244fe
--- /dev/null
+++ b/src/main/java/com/desafiotecnico/matera/account/repository/AccountRepository.java
@@ -0,0 +1,17 @@
+package com.desafiotecnico.matera.account.repository;
+
+import com.desafiotecnico.matera.account.domain.Account;
+import jakarta.persistence.LockModeType;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Lock;
+import org.springframework.data.jpa.repository.Query;
+
+import java.util.Optional;
+
+public interface AccountRepository extends JpaRepository {
+ Optional findByNumber(String number);
+
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ @Query("select a from Account a where a.id = :id")
+ Optional findByIdForUpdate(String id);
+}
\ No newline at end of file
diff --git a/src/main/java/com/desafiotecnico/matera/account/repository/TransactionRepository.java b/src/main/java/com/desafiotecnico/matera/account/repository/TransactionRepository.java
new file mode 100644
index 0000000..59a04ed
--- /dev/null
+++ b/src/main/java/com/desafiotecnico/matera/account/repository/TransactionRepository.java
@@ -0,0 +1,10 @@
+package com.desafiotecnico.matera.account.repository;
+
+import com.desafiotecnico.matera.account.domain.Transaction;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface TransactionRepository extends JpaRepository {
+ List findByAccountIdOrderByCreatedAtDesc(String accountId);
+}
\ No newline at end of file
diff --git a/src/main/java/com/desafiotecnico/matera/account/service/AccountService.java b/src/main/java/com/desafiotecnico/matera/account/service/AccountService.java
new file mode 100644
index 0000000..383aac3
--- /dev/null
+++ b/src/main/java/com/desafiotecnico/matera/account/service/AccountService.java
@@ -0,0 +1,94 @@
+package com.desafiotecnico.matera.account.service;
+
+import com.desafiotecnico.matera.account.domain.Account;
+import com.desafiotecnico.matera.account.domain.Transaction;
+import com.desafiotecnico.matera.account.domain.TransactionType;
+import com.desafiotecnico.matera.account.dto.BalanceResponse;
+import com.desafiotecnico.matera.account.dto.CreateAccountRequest;
+import com.desafiotecnico.matera.account.dto.TransactionBatchRequest;
+import com.desafiotecnico.matera.account.dto.TransactionRequest;
+import com.desafiotecnico.matera.account.repository.AccountRepository;
+import com.desafiotecnico.matera.account.repository.TransactionRepository;
+import com.desafiotecnico.matera.shared.exception.NotFoundException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.orm.ObjectOptimisticLockingFailureException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class AccountService {
+
+ private final AccountRepository accountRepository;
+
+ private final TransactionRepository transactionRepository;
+
+ @Transactional
+ public Account createAccount(CreateAccountRequest request) {
+ Account account = Account.builder()
+ .number(request.number())
+ .balance(
+ request.initialBalance() != null ? request.initialBalance() : BigDecimal.ZERO
+ )
+ .build();
+ return accountRepository.save(account);
+ }
+
+ @Transactional
+ public void applyTransactions(String accountId, TransactionBatchRequest batch) {
+ Account account = accountRepository.findByIdForUpdate(accountId)
+ .orElseThrow(() -> new NotFoundException("Conta não encontrada"));
+
+ for (TransactionRequest tr : batch.transactions()) {
+ if (tr.type() == TransactionType.CREDIT) {
+ account.credit(tr.amount());
+ } else if (tr.type() == TransactionType.DEBIT) {
+ account.debit(tr.amount());
+ }
+
+ Transaction tx = Transaction.builder()
+ .account(account)
+ .type(tr.type())
+ .amount(tr.amount())
+ .build();
+ transactionRepository.save(tx);
+ }
+
+ accountRepository.save(account);
+ }
+
+
+ @Transactional(readOnly = true)
+ public BalanceResponse getBalance(String accountId) {
+ Account account = accountRepository.findById(accountId)
+ .orElseThrow(() -> new NotFoundException("Conta não encontrada"));
+
+ return new BalanceResponse(account.getId(), account.getNumber(), account.getBalance());
+ }
+
+ @Transactional
+ public void applyTransactionsWithRetry(String accountId, TransactionBatchRequest batch) {
+ applyTransactions(accountId, batch);
+ }
+
+ @Transactional
+ protected void doApplyTransactions(String accountId, TransactionBatchRequest batch) {
+ applyTransactions(accountId, batch);
+ }
+
+ @Transactional(readOnly = true)
+ public List listAccounts() {
+ return accountRepository.findAll().stream()
+ .map(account -> new BalanceResponse(
+ account.getId(),
+ account.getNumber(),
+ account.getBalance()
+ ))
+ .collect(Collectors.toList());
+ }
+}
diff --git a/src/main/java/com/desafiotecnico/matera/config/CorsConfig.java b/src/main/java/com/desafiotecnico/matera/config/CorsConfig.java
new file mode 100644
index 0000000..4006c87
--- /dev/null
+++ b/src/main/java/com/desafiotecnico/matera/config/CorsConfig.java
@@ -0,0 +1,27 @@
+package com.desafiotecnico.matera.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+@Configuration
+public class CorsConfig {
+
+ @Bean
+ public WebMvcConfigurer corsConfigurer() {
+ return new WebMvcConfigurer() {
+ @Override
+ public void addCorsMappings(CorsRegistry registry) {
+ registry.addMapping("/api/**")
+ .allowedOrigins(
+ "http://localhost:5173",
+ "http://localhost:8081"
+ )
+ .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
+ .allowedHeaders("*")
+ .allowCredentials(false);
+ }
+ };
+ }
+}
diff --git a/src/main/java/com/desafiotecnico/matera/config/DataSeeder.java b/src/main/java/com/desafiotecnico/matera/config/DataSeeder.java
new file mode 100644
index 0000000..1ab8ea4
--- /dev/null
+++ b/src/main/java/com/desafiotecnico/matera/config/DataSeeder.java
@@ -0,0 +1,68 @@
+package com.desafiotecnico.matera.config;
+
+import com.desafiotecnico.matera.account.domain.Account;
+import com.desafiotecnico.matera.account.domain.Transaction;
+import com.desafiotecnico.matera.account.domain.TransactionType;
+import com.desafiotecnico.matera.account.repository.AccountRepository;
+import com.desafiotecnico.matera.account.repository.TransactionRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+
+@Configuration
+@RequiredArgsConstructor
+public class DataSeeder {
+ private final AccountRepository accountRepository;
+ private final TransactionRepository transactionRepository;
+
+ @Bean
+ @Transactional
+ public org.springframework.boot.CommandLineRunner seedData() {
+ return args -> {
+ if (accountRepository.count() > 0) {
+ return;
+ }
+
+ Account acc1 = Account.builder()
+ .number("ACC-1001")
+ .balance(BigDecimal.valueOf(1000.00))
+ .build();
+
+ Account acc2 = Account.builder()
+ .number("ACC-2001")
+ .balance(BigDecimal.valueOf(500.00))
+ .build();
+
+ Account acc3 = Account.builder()
+ .number("ACC-3001")
+ .balance(BigDecimal.ZERO)
+ .build();
+
+ acc1 = accountRepository.save(acc1);
+ acc2 = accountRepository.save(acc2);
+ acc3 = accountRepository.save(acc3);
+
+ Transaction t1 = Transaction.builder()
+ .account(acc1)
+ .type(TransactionType.CREDIT)
+ .amount(BigDecimal.valueOf(200.00))
+ .build();
+
+ Transaction t2 = Transaction.builder()
+ .account(acc1)
+ .type(TransactionType.DEBIT)
+ .amount(BigDecimal.valueOf(50.00))
+ .build();
+
+ acc1.credit(t1.getAmount());
+ acc1.debit(t2.getAmount());
+
+ transactionRepository.save(t1);
+ transactionRepository.save(t2);
+ accountRepository.save(acc1);
+ };
+ }
+}
diff --git a/src/main/java/com/desafiotecnico/matera/config/OpenApiConfig.java b/src/main/java/com/desafiotecnico/matera/config/OpenApiConfig.java
new file mode 100644
index 0000000..5c66933
--- /dev/null
+++ b/src/main/java/com/desafiotecnico/matera/config/OpenApiConfig.java
@@ -0,0 +1,25 @@
+package com.desafiotecnico.matera.config;
+
+import io.swagger.v3.oas.annotations.OpenAPIDefinition;
+import io.swagger.v3.oas.annotations.info.Contact;
+import io.swagger.v3.oas.annotations.info.Info;
+import io.swagger.v3.oas.annotations.info.License;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@OpenAPIDefinition(
+ info = @Info(
+ title = "Bank API - Desafio Técnico Matera",
+ version = "1.0.0",
+ description = "API RESTful para lançamentos bancários (débito/crédito), com controle de concorrência e histórico de transações.",
+ contact = @Contact(
+ name = "Yago Martins",
+ email = "yagolopesmartins777@gmail.com"
+ ),
+ license = @License(
+ name = "Uso exclusivo para avaliação técnica"
+ )
+ )
+)
+public class OpenApiConfig {
+}
diff --git a/src/main/java/com/desafiotecnico/matera/shared/error/ApiErrorResponse.java b/src/main/java/com/desafiotecnico/matera/shared/error/ApiErrorResponse.java
new file mode 100644
index 0000000..24ca429
--- /dev/null
+++ b/src/main/java/com/desafiotecnico/matera/shared/error/ApiErrorResponse.java
@@ -0,0 +1,4 @@
+package com.desafiotecnico.matera.shared.error;
+
+public record ApiErrorResponse(String code, String message) {
+}
diff --git a/src/main/java/com/desafiotecnico/matera/shared/error/ErrorResponse.java b/src/main/java/com/desafiotecnico/matera/shared/error/ErrorResponse.java
new file mode 100644
index 0000000..b3b7c43
--- /dev/null
+++ b/src/main/java/com/desafiotecnico/matera/shared/error/ErrorResponse.java
@@ -0,0 +1,3 @@
+package com.desafiotecnico.matera.shared.error;
+
+public record ErrorResponse (String code, String message) {}
\ No newline at end of file
diff --git a/src/main/java/com/desafiotecnico/matera/shared/exception/ApiExceptionHandler.java b/src/main/java/com/desafiotecnico/matera/shared/exception/ApiExceptionHandler.java
new file mode 100644
index 0000000..9afe63c
--- /dev/null
+++ b/src/main/java/com/desafiotecnico/matera/shared/exception/ApiExceptionHandler.java
@@ -0,0 +1,60 @@
+package com.desafiotecnico.matera.shared.exception;
+
+import com.desafiotecnico.matera.shared.error.ApiErrorResponse;
+import org.springframework.http.HttpStatus;
+import org.springframework.orm.ObjectOptimisticLockingFailureException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import java.util.stream.Collectors;
+
+@RestControllerAdvice
+public class ApiExceptionHandler {
+
+ @ExceptionHandler(NotFoundException.class)
+ @ResponseStatus(HttpStatus.NOT_FOUND)
+ public ApiErrorResponse handleNotFound(NotFoundException ex) {
+ return new ApiErrorResponse("NOT_FOUND", ex.getMessage());
+ }
+
+ @ExceptionHandler(InsufficientBalanceException.class)
+ @ResponseStatus(code = HttpStatus.UNPROCESSABLE_ENTITY)
+ public ApiErrorResponse handleInsufficient(InsufficientBalanceException ex) {
+ return new ApiErrorResponse("INSUFFICIENT_BALANCE", ex.getMessage());
+ }
+
+ // genérico para outras BusinessException que você criar no futuro
+ @ExceptionHandler(BusinessException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public ApiErrorResponse handleBusiness(BusinessException ex) {
+ return new ApiErrorResponse("BUSINESS_ERROR", ex.getMessage());
+ }
+
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public ApiErrorResponse handleValidation(MethodArgumentNotValidException ex) {
+ String message = ex.getBindingResult().getFieldErrors().stream()
+ .map(fe -> fe.getField() + " " + fe.getDefaultMessage())
+ .collect(Collectors.joining(", "));
+ return new ApiErrorResponse("VALIDATION_ERROR", message);
+ }
+
+ @ExceptionHandler(ObjectOptimisticLockingFailureException.class)
+ @ResponseStatus(HttpStatus.CONFLICT)
+ public ApiErrorResponse handleOptimisticLock(ObjectOptimisticLockingFailureException ex) {
+ return new ApiErrorResponse(
+ "CONCURRENT_MODIFICATION",
+ "A conta foi modificada por outra transação. Tente novamente."
+ );
+ }
+
+ // fallback pra qualquer erro inesperado
+ @ExceptionHandler(Exception.class)
+ @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
+ public ApiErrorResponse handleUnexpected(Exception ex) {
+ // aqui em prod você logaria o stack trace (log.error)
+ return new ApiErrorResponse("INTERNAL_ERROR", "Ocorreu um erro inesperado.");
+ }
+}
diff --git a/src/main/java/com/desafiotecnico/matera/shared/exception/BusinessException.java b/src/main/java/com/desafiotecnico/matera/shared/exception/BusinessException.java
new file mode 100644
index 0000000..e4b4b22
--- /dev/null
+++ b/src/main/java/com/desafiotecnico/matera/shared/exception/BusinessException.java
@@ -0,0 +1,16 @@
+package com.desafiotecnico.matera.shared.exception;
+
+/**
+ * Exceção base para erros de regra de negócio.
+ * Pode ser usada diretamente ou estendida por exceções mais específicas.
+ */
+public class BusinessException extends RuntimeException {
+
+ public BusinessException(String message) {
+ super(message);
+ }
+
+ public BusinessException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/main/java/com/desafiotecnico/matera/shared/exception/InsufficientBalanceException.java b/src/main/java/com/desafiotecnico/matera/shared/exception/InsufficientBalanceException.java
new file mode 100644
index 0000000..b5d1aac
--- /dev/null
+++ b/src/main/java/com/desafiotecnico/matera/shared/exception/InsufficientBalanceException.java
@@ -0,0 +1,8 @@
+package com.desafiotecnico.matera.shared.exception;
+
+public class InsufficientBalanceException extends BusinessException {
+
+ public InsufficientBalanceException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/com/desafiotecnico/matera/shared/exception/NotFoundException.java b/src/main/java/com/desafiotecnico/matera/shared/exception/NotFoundException.java
new file mode 100644
index 0000000..81cb4b0
--- /dev/null
+++ b/src/main/java/com/desafiotecnico/matera/shared/exception/NotFoundException.java
@@ -0,0 +1,8 @@
+package com.desafiotecnico.matera.shared.exception;
+
+public class NotFoundException extends BusinessException {
+
+ public NotFoundException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 8756be7..0ebba87 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -1 +1,11 @@
spring.application.name=matera
+spring.datasource.url=jdbc:postgresql://localhost:5432/bank
+spring.datasource.username=bankuser
+spring.datasource.password=bankpass
+
+spring.jpa.hibernate.ddl-auto=update
+spring.jpa.show-sql=true
+spring.jpa.properties.hibernate.format_sql=true
+
+spring.jpa.properties.hibernate.jdbc.time_zone=UTC
+
diff --git a/src/test/java/com/desafiotecnico/matera/MateraApplicationTests.java b/src/test/java/com/desafiotecnico/matera/MateraApplicationTests.java
index ba671ae..ec90a94 100644
--- a/src/test/java/com/desafiotecnico/matera/MateraApplicationTests.java
+++ b/src/test/java/com/desafiotecnico/matera/MateraApplicationTests.java
@@ -2,8 +2,10 @@
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
@SpringBootTest
+@ActiveProfiles("test")
class MateraApplicationTests {
@Test
diff --git a/src/test/java/com/desafiotecnico/matera/account/api/AccountControllerTest.java b/src/test/java/com/desafiotecnico/matera/account/api/AccountControllerTest.java
new file mode 100644
index 0000000..c8dd868
--- /dev/null
+++ b/src/test/java/com/desafiotecnico/matera/account/api/AccountControllerTest.java
@@ -0,0 +1,71 @@
+package com.desafiotecnico.matera.account.api;
+
+import com.desafiotecnico.matera.account.domain.Account;
+import com.desafiotecnico.matera.account.dto.CreateAccountRequest;
+import com.desafiotecnico.matera.account.service.AccountService;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.math.BigDecimal;
+
+import static org.hamcrest.Matchers.is;
+import static org.mockito.ArgumentMatchers.any;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+@WebMvcTest(AccountController.class)
+@ActiveProfiles("test")
+class AccountControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @MockBean
+ private AccountService accountService;
+
+ @Test
+ void should_create_account_and_return_201() throws Exception {
+ Account account = Account.builder()
+ .id("id-123")
+ .number("12345-0")
+ .balance(BigDecimal.valueOf(1000L))
+ .build();
+
+ Mockito.when(accountService.createAccount(any(CreateAccountRequest.class)))
+ .thenReturn(account);
+
+ mockMvc.perform(post("/api/accounts")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {
+ "number": "12345-0",
+ "initialBalance": 1000.00
+ }
+ """))
+ .andExpect(status().isCreated())
+ .andExpect(header().string("Location", "/api/accounts/id-123"))
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$.id", is("id-123")))
+ .andExpect(jsonPath("$.number", is("12345-0")))
+ .andExpect(jsonPath("$.balance").value(1000.0));
+ }
+
+ @Test
+ void should_return_400_when_payload_is_invalid() throws Exception {
+ mockMvc.perform(post("/api/accounts")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {
+ "number": "",
+ "initialBalance": -10
+ }
+ """))
+ .andExpect(status().isBadRequest());
+ }
+}
diff --git a/src/test/java/com/desafiotecnico/matera/account/service/AccountServiceTest.java b/src/test/java/com/desafiotecnico/matera/account/service/AccountServiceTest.java
new file mode 100644
index 0000000..9cd0dfa
--- /dev/null
+++ b/src/test/java/com/desafiotecnico/matera/account/service/AccountServiceTest.java
@@ -0,0 +1,165 @@
+package com.desafiotecnico.matera.account.service;
+
+import com.desafiotecnico.matera.account.domain.Account;
+import com.desafiotecnico.matera.account.domain.TransactionType;
+import com.desafiotecnico.matera.account.dto.BalanceResponse;
+import com.desafiotecnico.matera.account.dto.CreateAccountRequest;
+import com.desafiotecnico.matera.account.dto.TransactionBatchRequest;
+import com.desafiotecnico.matera.account.dto.TransactionRequest;
+import com.desafiotecnico.matera.account.repository.AccountRepository;
+import com.desafiotecnico.matera.account.repository.TransactionRepository;
+import com.desafiotecnico.matera.shared.exception.InsufficientBalanceException;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.*;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.fail;
+
+@SpringBootTest
+@ActiveProfiles("test")
+class AccountServiceTest {
+
+ @Autowired
+ private AccountService accountService;
+
+ @Autowired
+ private AccountRepository accountRepository;
+
+ @Autowired
+ private TransactionRepository transactionRepository;
+
+ @AfterEach
+ void cleanup() {
+ transactionRepository.deleteAllInBatch();
+ accountRepository.deleteAllInBatch();
+ }
+
+ @Test
+ void should_apply_credit_and_debit_in_batch() {
+ Account account = accountService.createAccount(
+ new CreateAccountRequest("9876-0", BigDecimal.valueOf(1000L))
+ );
+
+ TransactionBatchRequest batch = new TransactionBatchRequest(
+ List.of(
+ new TransactionRequest(TransactionType.DEBIT, BigDecimal.valueOf(100L)),
+ new TransactionRequest(TransactionType.CREDIT, BigDecimal.valueOf(50L))
+ )
+ );
+
+ accountService.applyTransactionsWithRetry(account.getId(), batch);
+
+ BalanceResponse balance = accountService.getBalance(account.getId());
+ assertThat(balance.balance()).isEqualByComparingTo("950.00");
+ }
+
+ @Test
+ void should_throw_when_insufficient_balance() {
+ Account account = accountService.createAccount(
+ new CreateAccountRequest("9999-0", BigDecimal.valueOf(50L))
+ );
+
+ TransactionBatchRequest batch = new TransactionBatchRequest(
+ List.of(
+ new TransactionRequest(TransactionType.DEBIT, BigDecimal.valueOf(100L))
+ )
+ );
+
+ assertThrows(InsufficientBalanceException.class,
+ () -> accountService.applyTransactionsWithRetry(account.getId(), batch));
+ }
+
+ @Test
+ void should_handle_concurrent_transactions_on_same_account() throws Exception {
+ Account account = accountService.createAccount(
+ new CreateAccountRequest("ACC-CONCURRENT", BigDecimal.valueOf(1000L))
+ );
+
+ String accountId = account.getId();
+
+ final int threads = 10;
+ final int operationsPerThread = 20;
+
+ ExecutorService executor = Executors.newFixedThreadPool(threads);
+ List> tasks = new ArrayList<>();
+
+ for (int i = 0; i < threads; i++) {
+ tasks.add(() -> {
+ for (int j = 0; j < operationsPerThread; j++) {
+ TransactionBatchRequest batch = new TransactionBatchRequest(
+ List.of(
+ new TransactionRequest(TransactionType.CREDIT, BigDecimal.TEN),
+ new TransactionRequest(TransactionType.DEBIT, BigDecimal.valueOf(5L))
+ )
+ );
+ accountService.applyTransactionsWithRetry(accountId, batch);
+ }
+ return null;
+ });
+ }
+
+ List> futures = executor.invokeAll(tasks);
+ executor.shutdown();
+ boolean finished = executor.awaitTermination(1, TimeUnit.MINUTES);
+ if (!finished) {
+ executor.shutdownNow();
+ fail("Executor did not finish within the timeout");
+ }
+
+ for (Future f : futures) {
+ f.get(); // Propaga qualquer exceção das tasks
+ }
+
+ int totalBatches = threads * operationsPerThread;
+ BigDecimal expectedDelta = BigDecimal.valueOf(totalBatches * 5L);
+ BigDecimal expected = BigDecimal.valueOf(1000L).add(expectedDelta);
+
+ BalanceResponse balance = accountService.getBalance(accountId);
+ assertEquals(0, balance.balance().compareTo(expected));
+ }
+
+ @Test
+ void concurrent_batches_keep_balance_consistent() throws Exception {
+ Account acc = accountService.createAccount(
+ new CreateAccountRequest("ACC-CONC-1", new BigDecimal("1000.00"))
+ );
+
+ TransactionBatchRequest batch = new TransactionBatchRequest(
+ List.of(
+ new TransactionRequest(TransactionType.CREDIT, new BigDecimal("10.00")),
+ new TransactionRequest(TransactionType.DEBIT, new BigDecimal("5.00"))
+ )
+ );
+
+ int threads = 10;
+ int batchesPerThread = 10;
+
+ ExecutorService executor = Executors.newFixedThreadPool(threads);
+ for (int i = 0; i < threads * batchesPerThread; i++) {
+ executor.submit(() -> accountService.applyTransactionsWithRetry(acc.getId(), batch));
+ }
+ executor.shutdown();
+ boolean finished = executor.awaitTermination(30, TimeUnit.SECONDS);
+ if (!finished) {
+ executor.shutdownNow();
+ fail("Executor did not finish within the timeout");
+ }
+
+ Account updated = accountRepository.findById(acc.getId()).orElseThrow();
+
+ BigDecimal expected = new BigDecimal("1000.00")
+ .add(new BigDecimal("5.00").multiply(BigDecimal.valueOf(threads * batchesPerThread)));
+
+ assertThat(updated.getBalance()).isEqualByComparingTo(expected);
+ }
+}
diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties
new file mode 100644
index 0000000..6968ea1
--- /dev/null
+++ b/src/test/resources/application-test.properties
@@ -0,0 +1,14 @@
+spring.datasource.url=jdbc:h2:mem:banktest;DB_CLOSE_DELAY=-1;MODE=PostgreSQL
+spring.datasource.driver-class-name=org.h2.Driver
+spring.datasource.username=sa
+spring.datasource.password=
+
+spring.jpa.hibernate.ddl-auto=create-drop
+spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
+
+spring.test.database.replace=none
+spring.sql.init.mode=never
+
+springdoc.api-docs.enabled=false
+springdoc.swagger-ui.enabled=false
+